[Phase 0/3] Scaffold vin-decoder repo: server, data layer, Flux manifests, CI #1
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.db
|
||||
.env
|
||||
dist/
|
||||
+23
@@ -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"]
|
||||
@@ -1,3 +1,34 @@
|
||||
# vin-decoder
|
||||
|
||||
VIN Decoder API — decode any 17-character VIN using NHTSA vPIC
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vin-decoder
|
||||
+405
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user