From 67097cf97687edaf6bf6b1b9a537c147ad841533 Mon Sep 17 00:00:00 2001 From: agent-company Date: Sat, 30 May 2026 20:08:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20scaffold=20vin-decoder=20repo=20?= =?UTF-8?q?=E2=80=94=20server,=20data=20layer,=20Flux=20manifests,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: - flux/vin-decoder/: Namespace, Deployment (with RAPIDAPI_PROXY_SECRET from secret), Service, Ingress (vin.leeworks.dev + TLS), Kustomization - .gitignore: node_modules, data/, .env --- .gitea/workflows/ci.yaml | 39 +++ .gitignore | 5 + Dockerfile | 23 ++ README.md | 33 ++- flux/vin-decoder/deployment.yaml | 71 +++++ flux/vin-decoder/ingress.yaml | 25 ++ flux/vin-decoder/kustomization.yaml | 6 + flux/vin-decoder/namespace.yaml | 4 + openapi.yaml | 405 ++++++++++++++++++++++++++++ package.json | 28 ++ scripts/seed.js | 29 ++ src/cache.js | 77 ++++++ src/db.js | 47 ++++ src/nhtsa.js | 46 ++++ src/server.js | 112 ++++++++ src/tests/cache.test.js | 54 ++++ 16 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 flux/vin-decoder/deployment.yaml create mode 100644 flux/vin-decoder/ingress.yaml create mode 100644 flux/vin-decoder/kustomization.yaml create mode 100644 flux/vin-decoder/namespace.yaml create mode 100644 openapi.yaml create mode 100644 package.json create mode 100644 scripts/seed.js create mode 100644 src/cache.js create mode 100644 src/db.js create mode 100644 src/nhtsa.js create mode 100644 src/server.js create mode 100644 src/tests/cache.test.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..8c64260 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,39 @@ +name: CI — Build, Test, Push + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm test + + build-and-push: + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - name: Set image tag + id: tag + run: echo "SHA=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT + - name: Log in to Gitea registry + run: echo "${{ secrets.GITEA_TOKEN }}" | docker login registry.leeworks.dev -u leeworks-agents --password-stdin + - name: Build image + run: | + docker build -t registry.leeworks.dev/vin-decoder/api:${{ steps.tag.outputs.SHA }} \ + -t registry.leeworks.dev/vin-decoder/api:latest . + - name: Push image + run: | + docker push registry.leeworks.dev/vin-decoder/api:${{ steps.tag.outputs.SHA }} + docker push registry.leeworks.dev/vin-decoder/api:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33c694e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +*.db +.env +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8cd93bc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-alpine AS base + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src/ ./src/ +COPY scripts/ ./scripts/ +COPY openapi.yaml ./ + +RUN mkdir -p /app/data + +EXPOSE 3000 + +ENV NODE_ENV=production +ENV DB_PATH=/app/data/vin_cache.db +ENV PORT=3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3000/v1/health || exit 1 + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md index f37b9e6..8c8330e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # vin-decoder -VIN Decoder API — decode any 17-character VIN using NHTSA vPIC \ No newline at end of file +VIN Decoder API - decode any 17-character VIN using NHTSA vPIC + +**Live at:** https://vin.leeworks.dev (once deployed) +**OpenAPI spec:** openapi.yaml + +## Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | /v1/decode?vin={vin} | Required | Decode a single VIN | +| POST | /v1/batch | Required | Decode up to 50 VINs | +| GET | /v1/health | None | Service health and cache stats | + +Auth = X-RapidAPI-Proxy-Secret header. + +## Local development + +npm install +node scripts/seed.js # optional: seed known VINs +npm run dev # starts on localhost:3000 + +## Tests + +npm test + +## Environment variables + +PORT (default 3000), DB_PATH (default ./data/vin_cache.db), RAPIDAPI_PROXY_SECRET (required in prod) + +## Deployment + +Flux manifests: flux/vin-decoder/. Requires gitea-registry imagePullSecret and rapidapi-proxy-secret in vin-decoder namespace. diff --git a/flux/vin-decoder/deployment.yaml b/flux/vin-decoder/deployment.yaml new file mode 100644 index 0000000..a4743a3 --- /dev/null +++ b/flux/vin-decoder/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vin-decoder + namespace: vin-decoder + labels: + app: vin-decoder +spec: + replicas: 1 + selector: + matchLabels: + app: vin-decoder + template: + metadata: + labels: + app: vin-decoder + spec: + imagePullSecrets: + - name: gitea-registry + containers: + - name: api + image: registry.leeworks.dev/vin-decoder/api:latest + ports: + - containerPort: 3000 + env: + - name: PORT + value: "3000" + - name: DB_PATH + value: /data/vin_cache.db + - name: RAPIDAPI_PROXY_SECRET + valueFrom: + secretKeyRef: + name: rapidapi-proxy-secret + key: X-RapidAPI-Proxy-Secret + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + httpGet: + path: /v1/health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /v1/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: vin-decoder + namespace: vin-decoder +spec: + selector: + app: vin-decoder + ports: + - port: 3000 + targetPort: 3000 diff --git a/flux/vin-decoder/ingress.yaml b/flux/vin-decoder/ingress.yaml new file mode 100644 index 0000000..9fb8ff8 --- /dev/null +++ b/flux/vin-decoder/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vin-decoder + namespace: vin-decoder + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + ingressClassName: nginx + tls: + - hosts: + - vin.leeworks.dev + secretName: vin-tls + rules: + - host: vin.leeworks.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: vin-decoder + port: + number: 3000 diff --git a/flux/vin-decoder/kustomization.yaml b/flux/vin-decoder/kustomization.yaml new file mode 100644 index 0000000..34a36c7 --- /dev/null +++ b/flux/vin-decoder/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml + - ingress.yaml diff --git a/flux/vin-decoder/namespace.yaml b/flux/vin-decoder/namespace.yaml new file mode 100644 index 0000000..5ee4e4c --- /dev/null +++ b/flux/vin-decoder/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vin-decoder diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..7e40b3a --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,405 @@ +openapi: 3.1.0 +info: + title: VIN Decoder API + version: 1.0.0 + description: | + Decode any 17-character Vehicle Identification Number (VIN) into structured + vehicle data including make, model, year, trim, engine, body style, and more. + Powered by the NHTSA vPIC public-domain database. + + **Data source:** NHTSA Product Information Catalog and Vehicle Listing (vPIC) + public API - US federal government data, public domain (17 U.S.C. 105). + + **Coverage:** Model years 1981-present, all major manufacturers registered + with NHTSA (domestic and import). + + **Caching:** Decoded results are cached for 90 days; cache status is + indicated by the X-Cache response header. + contact: + name: leeworks.dev API Support + url: https://docs.leeworks.dev + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://vin.leeworks.dev/v1 + description: Production + +security: + - RapidApiProxy: [] + +tags: + - name: decode + description: VIN decoding endpoints + - name: health + description: Service health and observability + +paths: + /decode: + get: + operationId: decodeVin + summary: Decode a single VIN + description: | + Decodes a 17-character VIN and returns structured vehicle attributes. + Results are cached for 90 days; a cache hit is indicated by + X-Cache: HIT in the response headers. + tags: + - decode + parameters: + - name: vin + in: query + required: true + description: 17-character Vehicle Identification Number (uppercase, no I/O/Q). + schema: + type: string + minLength: 17 + maxLength: 17 + pattern: "^[A-HJ-NPR-Z0-9]{17}$" + example: 1HGCM82633A004352 + - name: raw + in: query + required: false + description: If true, include raw NHTSA vPIC fields in the response. + schema: + type: boolean + default: false + responses: + "200": + description: VIN successfully decoded + headers: + X-Cache: + schema: + type: string + enum: [HIT, MISS] + description: Whether the result was served from cache + X-Data-Source: + schema: + type: string + description: Upstream data source identifier + X-Request-Id: + schema: + type: string + description: Unique request identifier + content: + application/json: + schema: + $ref: "#/components/schemas/VinDecodeResult" + "400": + description: Invalid VIN format or missing parameter + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Missing or invalid RapidAPI proxy secret + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded for your plan + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error or upstream NHTSA API failure + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /batch: + post: + operationId: decodeVinBatch + summary: Decode up to 50 VINs in a single request + description: | + Accepts a JSON body with an array of VINs (1-50) and returns a decoded + result for each. Each VIN is processed independently; partial failures + return an error object in that position rather than failing the whole batch. + tags: + - decode + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - vins + properties: + vins: + type: array + minItems: 1 + maxItems: 50 + items: + type: string + minLength: 17 + maxLength: 17 + pattern: "^[A-HJ-NPR-Z0-9]{17}$" + description: Array of 17-character VINs to decode + example: + vins: + - 1HGCM82633A004352 + - WBABW33486PX01612 + responses: + "200": + description: Batch decode results (one entry per input VIN, in order) + headers: + X-Request-Id: + schema: + type: string + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + oneOf: + - $ref: "#/components/schemas/VinDecodeResult" + - $ref: "#/components/schemas/VinDecodeError" + count: + type: integer + description: Total number of VINs processed + cached_count: + type: integer + description: Number of results served from cache + error_count: + type: integer + description: Number of VINs that could not be decoded + "400": + description: Invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Missing or invalid RapidAPI proxy secret + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /health: + get: + operationId: healthCheck + summary: Service health check + description: | + Returns health status of the VIN Decoder service including cache + statistics and NHTSA API reachability. Does not require X-RapidAPI-Proxy-Secret. + tags: + - health + security: [] + responses: + "200": + description: Service is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + "503": + description: Service is degraded (upstream unreachable or DB error) + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + +components: + securitySchemes: + RapidApiProxy: + type: apiKey + in: header + name: X-RapidAPI-Proxy-Secret + description: | + RapidAPI proxy secret injected automatically by RapidAPI on every + subscriber request. Direct callers must include this header manually. + + schemas: + VinDecodeResult: + type: object + required: + - vin + - error_code + properties: + vin: + type: string + description: The input VIN (uppercased) + example: 1HGCM82633A004352 + make: + type: ["string", "null"] + description: Vehicle manufacturer brand + example: HONDA + model: + type: ["string", "null"] + description: Vehicle model name + example: Accord + model_year: + type: ["string", "null"] + description: Model year as a 4-digit string + example: "2003" + trim: + type: ["string", "null"] + description: Trim level (e.g. EX, LX, Sport) + example: EX + series: + type: ["string", "null"] + description: Series designation if applicable + body_class: + type: ["string", "null"] + description: Body style classification + example: Sedan/Saloon + drive_type: + type: ["string", "null"] + description: Drive configuration + example: FWD/Front-Wheel Drive + engine_displacement_cc: + type: ["number", "null"] + description: Engine displacement in cubic centimetres + example: 2354 + engine_displacement_l: + type: ["number", "null"] + description: Engine displacement in litres + example: 2.4 + engine_cylinders: + type: ["integer", "null"] + description: Number of engine cylinders + example: 4 + fuel_type_primary: + type: ["string", "null"] + description: Primary fuel type + example: Gasoline + transmission_style: + type: ["string", "null"] + description: Transmission type (Automatic, Manual, CVT, etc.) + example: Automatic + transmission_speeds: + type: ["string", "null"] + description: Number of transmission speeds as string + example: "5" + plant_city: + type: ["string", "null"] + description: Assembly plant city + example: MARYSVILLE + plant_state: + type: ["string", "null"] + description: Assembly plant state/province + example: OHIO + plant_country: + type: ["string", "null"] + description: Assembly plant country + example: UNITED STATES (USA) + manufacturer_name: + type: ["string", "null"] + description: Full legal name of the manufacturer + example: HONDA OF AMERICA MFG., INC. + vehicle_type: + type: ["string", "null"] + description: NHTSA vehicle type classification + example: PASSENGER CAR + error_code: + type: string + description: NHTSA decode error code. "0" means successful decode. + example: "0" + error_text: + type: ["string", "null"] + description: Human-readable decode error (null when error_code is "0") + cached: + type: boolean + description: Whether this result was served from the local cache + example: true + + VinDecodeError: + type: object + required: + - vin + - error + - message + properties: + vin: + type: string + description: The VIN that could not be decoded + error: + type: string + description: Error code + example: INVALID_VIN + message: + type: string + description: Human-readable error description + example: VIN must be exactly 17 alphanumeric characters + + Error: + type: object + required: + - error + - message + - status + properties: + error: + type: string + description: Machine-readable error code + example: BAD_REQUEST + message: + type: string + description: Human-readable error description + example: "Query parameter 'vin' is required" + status: + type: integer + description: HTTP status code + example: 400 + + HealthResponse: + type: object + required: + - status + - version + properties: + status: + type: string + enum: [ok, degraded] + description: Overall service health + version: + type: string + description: Service version + example: "1.0.0" + uptime_seconds: + type: integer + description: Seconds since the service started + example: 86400 + cache: + type: object + properties: + entries: + type: integer + description: Number of cached VIN records + hit_rate_24h: + type: number + description: Cache hit rate over the last 24 hours (0.0-1.0) + size_mb: + type: number + description: SQLite cache file size in megabytes + upstream: + type: object + properties: + nhtsa_vpic: + type: string + enum: [reachable, unreachable] + description: NHTSA vPIC API reachability + last_check: + type: string + format: date-time + description: ISO-8601 timestamp of last upstream health check diff --git a/package.json b/package.json new file mode 100644 index 0000000..47b749c --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "vin-decoder", + "version": "1.0.0", + "description": "VIN Decoder API — decode any 17-character VIN using NHTSA vPIC", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "test": "node --test src/tests/*.test.js", + "seed": "node scripts/seed.js", + "lint": "eslint src/ scripts/" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "fastify": "^4.28.0", + "better-sqlite3": "^9.6.0", + "node-fetch": "^3.3.2", + "pino": "^9.3.2", + "uuid": "^10.0.0" + }, + "devDependencies": { + "eslint": "^9.7.0" + }, + "license": "MIT" +} diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100644 index 0000000..af8b1ee --- /dev/null +++ b/scripts/seed.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Seed script — pre-warm the SQLite cache with a set of known VINs. + * Also used for the startup eviction sweep. + * Usage: node scripts/seed.js [VIN1 VIN2 ...] + */ +import { decodeVin, evictExpired } from '../src/cache.js'; + +const DEFAULT_VINS = [ + '1HGCM82633A004352', // 2003 Honda Accord EX + 'WBABW33486PX01612', // BMW +]; + +async function main() { + const vins = process.argv.slice(2).length > 0 ? process.argv.slice(2) : DEFAULT_VINS; + const evicted = evictExpired(); + console.log(`Evicted ${evicted} expired cache entries.`); + + for (const vin of vins) { + try { + const { result, fromCache } = await decodeVin(vin); + console.log(`${vin}: ${fromCache ? 'CACHE HIT' : 'FETCHED'} — ${result.make} ${result.model} ${result.model_year}`); + } catch (err) { + console.error(`${vin}: ERROR — ${err.message}`); + } + } +} + +main().catch(console.error); diff --git a/src/cache.js b/src/cache.js new file mode 100644 index 0000000..d1aa098 --- /dev/null +++ b/src/cache.js @@ -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, + }; +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..2a7b205 --- /dev/null +++ b/src/db.js @@ -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; diff --git a/src/nhtsa.js b/src/nhtsa.js new file mode 100644 index 0000000..687f3e3 --- /dev/null +++ b/src/nhtsa.js @@ -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 }; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..91b5052 --- /dev/null +++ b/src/server.js @@ -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 1–50 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); } +}); diff --git a/src/tests/cache.test.js b/src/tests/cache.test.js new file mode 100644 index 0000000..18aa608 --- /dev/null +++ b/src/tests/cache.test.js @@ -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); + }); +}); -- 2.52.0