feat: scaffold vin-decoder repo — server, data layer, Flux manifests, CI
CI — Build, Test, Push / test (pull_request) Failing after 6s
CI — Build, Test, Push / build-and-push (pull_request) Has been skipped

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
This commit is contained in:
agent-company
2026-05-30 20:08:47 +00:00
parent 79178119ba
commit 67097cf976
16 changed files with 1003 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
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,
};
}
+47
View File
@@ -0,0 +1,47 @@
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, mkdirSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || join(__dirname, '../data/vin_cache.db');
const DB_DIR = dirname(DB_PATH);
if (!existsSync(DB_DIR)) mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS vin_cache (
vin TEXT PRIMARY KEY,
make TEXT,
model TEXT,
model_year TEXT,
trim TEXT,
series TEXT,
body_class TEXT,
drive_type TEXT,
engine_displacement_cc REAL,
engine_displacement_l REAL,
engine_cylinders INTEGER,
fuel_type_primary TEXT,
transmission_style TEXT,
transmission_speeds TEXT,
plant_city TEXT,
plant_state TEXT,
plant_country TEXT,
manufacturer_name TEXT,
vehicle_type TEXT,
error_code TEXT,
error_text TEXT,
raw_nhtsa TEXT,
cached_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_vin_cache_expires ON vin_cache(expires_at);
`);
export default db;
+46
View File
@@ -0,0 +1,46 @@
import fetch from 'node-fetch';
const NHTSA_BASE = 'https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues';
const TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
/** Fetch decoded VIN data from NHTSA vPIC */
export async function fetchFromNhtsa(vin) {
const url = `${NHTSA_BASE}/${encodeURIComponent(vin)}?format=json`;
const res = await fetch(url, { timeout: 10000 });
if (!res.ok) throw new Error(`NHTSA returned HTTP ${res.status}`);
const json = await res.json();
const results = json.Results?.[0];
if (!results) throw new Error('NHTSA returned empty Results array');
return results;
}
/** Map NHTSA flat key/value response to our schema */
export function mapNhtsaToSchema(raw) {
const cc = raw.DisplacementCC ? parseFloat(raw.DisplacementCC) : null;
const litre = raw.DisplacementL ? parseFloat(raw.DisplacementL) : null;
const cylinders = raw.EngineCylinders ? parseInt(raw.EngineCylinders, 10) : null;
return {
make: raw.Make || null,
model: raw.Model || null,
model_year: raw.ModelYear || null,
trim: raw.Trim || null,
series: raw.Series || null,
body_class: raw.BodyClass || null,
drive_type: raw.DriveType || null,
engine_displacement_cc: isNaN(cc) ? null : cc,
engine_displacement_l: isNaN(litre) ? null : litre,
engine_cylinders: isNaN(cylinders) ? null : cylinders,
fuel_type_primary: raw.FuelTypePrimary || null,
transmission_style: raw.TransmissionStyle || null,
transmission_speeds: raw.TransmissionSpeeds || null,
plant_city: raw.PlantCity || null,
plant_state: raw.PlantState || null,
plant_country: raw.PlantCountry || null,
manufacturer_name: raw.Manufacturer || null,
vehicle_type: raw.VehicleType || null,
error_code: raw.ErrorCode || '0',
error_text: raw.ErrorText || null,
};
}
export { TTL_SECONDS };
+112
View File
@@ -0,0 +1,112 @@
import Fastify from 'fastify';
import { randomUUID } from 'crypto';
import { decodeVin, validateVin, getCacheStats, evictExpired } from './cache.js';
const PORT = parseInt(process.env.PORT || '3000', 10);
const PROXY_SECRET = process.env.RAPIDAPI_PROXY_SECRET || '';
const VERSION = process.env.npm_package_version || '1.0.0';
const fastify = Fastify({ logger: true });
const startTime = Date.now();
// Request ID
fastify.addHook('onRequest', async (req, reply) => {
const requestId = randomUUID();
req.requestId = requestId;
reply.header('X-Request-Id', requestId);
});
// Proxy secret validation helper
function requireProxySecret(request, reply) {
if (!PROXY_SECRET) return; // dev mode — skip validation if not set
const incoming = request.headers['x-rapidapi-proxy-secret'];
if (!incoming || incoming !== PROXY_SECRET) {
reply.code(403).send({ error: 'FORBIDDEN', message: 'Missing or invalid X-RapidAPI-Proxy-Secret', status: 403 });
return false;
}
return true;
}
// GET /v1/decode
fastify.get('/v1/decode', async (request, reply) => {
if (requireProxySecret(request, reply) === false) return;
const { vin, raw } = request.query;
if (!vin) {
return reply.code(400).send({ error: 'BAD_REQUEST', message: "Query parameter 'vin' is required", status: 400 });
}
if (!validateVin(vin)) {
return reply.code(400).send({ error: 'INVALID_VIN', message: 'VIN must be exactly 17 alphanumeric characters (no I, O, Q)', status: 400 });
}
try {
const { result, fromCache } = await decodeVin(vin);
reply.header('X-Cache', fromCache ? 'HIT' : 'MISS');
reply.header('X-Data-Source', 'NHTSA vPIC Public API');
if (!raw || raw === 'false') {
const { raw_nhtsa, cached_at, expires_at, ...clean } = result;
return clean;
}
return result;
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: 'UPSTREAM_ERROR', message: 'Failed to decode VIN: ' + err.message, status: 500 });
}
});
// POST /v1/batch
fastify.post('/v1/batch', async (request, reply) => {
if (requireProxySecret(request, reply) === false) return;
const { vins } = request.body || {};
if (!Array.isArray(vins) || vins.length === 0 || vins.length > 50) {
return reply.code(400).send({ error: 'BAD_REQUEST', message: 'Body must contain a "vins" array of 150 VINs', status: 400 });
}
let cached_count = 0;
let error_count = 0;
const results = await Promise.all(vins.map(async (vin) => {
if (!validateVin(vin)) {
error_count++;
return { vin, error: 'INVALID_VIN', message: 'VIN must be exactly 17 alphanumeric characters (no I, O, Q)' };
}
try {
const { result, fromCache } = await decodeVin(vin);
if (fromCache) cached_count++;
const { raw_nhtsa, cached_at, expires_at, ...clean } = result;
return clean;
} catch (err) {
error_count++;
return { vin, error: 'DECODE_ERROR', message: err.message };
}
}));
return { results, count: vins.length, cached_count, error_count };
});
// GET /v1/health
fastify.get('/v1/health', async (request, reply) => {
evictExpired();
const cacheStats = getCacheStats();
let nhtsaStatus = 'reachable';
try {
const testRes = await fetch('https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/1HGCM82633A004352?format=json', { signal: AbortSignal.timeout(5000) });
if (!testRes.ok) nhtsaStatus = 'unreachable';
} catch {
nhtsaStatus = 'unreachable';
}
const status = nhtsaStatus === 'reachable' ? 'ok' : 'degraded';
return reply.code(status === 'ok' ? 200 : 503).send({
status,
version: VERSION,
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
cache: { ...cacheStats, hit_rate_24h: null },
upstream: { nhtsa_vpic: nhtsaStatus, last_check: new Date().toISOString() },
});
});
fastify.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
if (err) { fastify.log.error(err); process.exit(1); }
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { validateVin, decodeVin, getCacheStats } from '../cache.js';
describe('validateVin', () => {
it('accepts valid 17-char VINs', () => {
assert.equal(validateVin('1HGCM82633A004352'), true);
assert.equal(validateVin('WBABW33486PX01612'), true);
});
it('rejects VINs with invalid characters (I, O, Q)', () => {
assert.equal(validateVin('1HGCM82633A00I352'), false); // I
assert.equal(validateVin('1HGCM82633A00O352'), false); // O
assert.equal(validateVin('1HGCM82633A00Q352'), false); // Q
});
it('rejects wrong length', () => {
assert.equal(validateVin('1HGCM82633A00435'), false); // 16
assert.equal(validateVin('1HGCM82633A0043521'), false); // 18
});
it('rejects non-strings', () => {
assert.equal(validateVin(null), false);
assert.equal(validateVin(undefined), false);
assert.equal(validateVin(12345), false);
});
});
describe('decodeVin — cache integration', () => {
it('decodes a known VIN (2003 Honda Accord)', async () => {
const testVin = '1HGCM82633A004352';
const { result, fromCache } = await decodeVin(testVin);
assert.equal(result.vin, testVin);
assert.ok(result.make, 'make should be present');
assert.ok(result.model_year, 'model_year should be present');
assert.equal(typeof result.error_code, 'string');
console.log(` decoded: ${result.make} ${result.model} ${result.model_year} (fromCache=${fromCache})`);
});
it('returns cache HIT on second call', async () => {
const testVin = '1HGCM82633A004352';
const { fromCache } = await decodeVin(testVin);
assert.equal(fromCache, true, 'second call should hit cache');
});
});
describe('getCacheStats', () => {
it('returns entries count and size_mb', () => {
const stats = getCacheStats();
assert.equal(typeof stats.entries, 'number');
assert.equal(typeof stats.size_mb, 'number');
assert.ok(stats.entries >= 0);
});
});