67097cf976
Closes leeworks-agents/api-company#122 - openapi.yaml: copied from leeworks-agents/api-company apis/vin-decoder/openapi.yaml (canonical spec) - src/db.js: SQLite database init with vin_cache schema and indexes - src/nhtsa.js: NHTSA vPIC fetch + field mapping - src/cache.js: cache read/write with 90-day TTL, validateVin, getCacheStats, evictExpired - src/server.js: Fastify server implementing GET /v1/decode, POST /v1/batch, GET /v1/health - X-RapidAPI-Proxy-Secret middleware on /decode and /batch - X-Request-Id, X-Cache, X-Data-Source response headers - Returns 403 for missing/wrong proxy secret - scripts/seed.js: pre-warm cache with known VINs, runs eviction sweep - src/tests/cache.test.js: unit tests for validateVin + cache integration (Honda Accord VIN) - Dockerfile: Node 20 alpine, non-root, healthcheck on /v1/health - .gitea/workflows/ci.yaml: test → build → push to registry.leeworks.dev/vin-decoder/api:<sha> - flux/vin-decoder/: Namespace, Deployment (with RAPIDAPI_PROXY_SECRET from secret), Service, Ingress (vin.leeworks.dev + TLS), Kustomization - .gitignore: node_modules, data/, .env
78 lines
3.1 KiB
JavaScript
78 lines
3.1 KiB
JavaScript
import db from './db.js';
|
|
import { fetchFromNhtsa, mapNhtsaToSchema, TTL_SECONDS } from './nhtsa.js';
|
|
|
|
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;
|
|
|
|
export function validateVin(vin) {
|
|
return typeof vin === 'string' && VIN_RE.test(vin.toUpperCase());
|
|
}
|
|
|
|
const getStmt = db.prepare(
|
|
'SELECT * FROM vin_cache WHERE vin = ? AND expires_at > unixepoch()'
|
|
);
|
|
|
|
const upsertStmt = db.prepare(`
|
|
INSERT INTO vin_cache
|
|
(vin, make, model, model_year, trim, series, body_class, drive_type,
|
|
engine_displacement_cc, engine_displacement_l, engine_cylinders,
|
|
fuel_type_primary, transmission_style, transmission_speeds,
|
|
plant_city, plant_state, plant_country, manufacturer_name,
|
|
vehicle_type, error_code, error_text, raw_nhtsa, cached_at, expires_at)
|
|
VALUES
|
|
(@vin, @make, @model, @model_year, @trim, @series, @body_class, @drive_type,
|
|
@engine_displacement_cc, @engine_displacement_l, @engine_cylinders,
|
|
@fuel_type_primary, @transmission_style, @transmission_speeds,
|
|
@plant_city, @plant_state, @plant_country, @manufacturer_name,
|
|
@vehicle_type, @error_code, @error_text, @raw_nhtsa, @cached_at, @expires_at)
|
|
ON CONFLICT(vin) DO UPDATE SET
|
|
make=excluded.make, model=excluded.model, model_year=excluded.model_year,
|
|
trim=excluded.trim, series=excluded.series, body_class=excluded.body_class,
|
|
drive_type=excluded.drive_type,
|
|
engine_displacement_cc=excluded.engine_displacement_cc,
|
|
engine_displacement_l=excluded.engine_displacement_l,
|
|
engine_cylinders=excluded.engine_cylinders,
|
|
fuel_type_primary=excluded.fuel_type_primary,
|
|
transmission_style=excluded.transmission_style,
|
|
transmission_speeds=excluded.transmission_speeds,
|
|
plant_city=excluded.plant_city, plant_state=excluded.plant_state,
|
|
plant_country=excluded.plant_country,
|
|
manufacturer_name=excluded.manufacturer_name,
|
|
vehicle_type=excluded.vehicle_type,
|
|
error_code=excluded.error_code, error_text=excluded.error_text,
|
|
raw_nhtsa=excluded.raw_nhtsa,
|
|
cached_at=excluded.cached_at, expires_at=excluded.expires_at
|
|
`);
|
|
|
|
const evictStmt = db.prepare('DELETE FROM vin_cache WHERE expires_at < unixepoch()');
|
|
|
|
export function evictExpired() {
|
|
return evictStmt.run().changes;
|
|
}
|
|
|
|
export async function decodeVin(vin) {
|
|
const upperVin = vin.toUpperCase();
|
|
const cached = getStmt.get(upperVin);
|
|
if (cached) {
|
|
return { result: { ...cached, cached: true }, fromCache: true };
|
|
}
|
|
const raw = await fetchFromNhtsa(upperVin);
|
|
const mapped = mapNhtsaToSchema(raw);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
upsertStmt.run({
|
|
vin: upperVin, ...mapped,
|
|
raw_nhtsa: JSON.stringify(raw),
|
|
cached_at: now,
|
|
expires_at: now + TTL_SECONDS,
|
|
});
|
|
return { result: { vin: upperVin, ...mapped, cached: false }, fromCache: false };
|
|
}
|
|
|
|
export function getCacheStats() {
|
|
const count = db.prepare('SELECT COUNT(*) AS c FROM vin_cache WHERE expires_at > unixepoch()').get();
|
|
const size = db.prepare("SELECT page_count * page_size AS sz FROM pragma_page_count(), pragma_page_size()").get();
|
|
return {
|
|
entries: count?.c ?? 0,
|
|
size_mb: size ? Math.round(size.sz / 1024 / 1024 * 100) / 100 : 0,
|
|
};
|
|
}
|