wenmar-open 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/batch.d.ts +5 -0
- package/dist/batch.js +7 -0
- package/dist/client.d.ts +80 -0
- package/dist/client.js +200 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +37 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/offline/client.d.ts +70 -0
- package/dist/offline/client.js +245 -0
- package/dist/offline/engine.d.ts +53 -0
- package/dist/offline/engine.js +129 -0
- package/dist/offline/index.d.ts +10 -0
- package/dist/offline/index.js +4 -0
- package/dist/offline/node.d.ts +20 -0
- package/dist/offline/node.js +78 -0
- package/dist/offline/store.d.ts +72 -0
- package/dist/offline/store.js +156 -0
- package/dist/offline/wasm-inline.js +2 -0
- package/dist/offline/wenmar_open.wasm +0 -0
- package/dist/schema.d.ts +984 -0
- package/dist/schema.js +3 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/package.json +62 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wenmar Pro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,326 @@
|
|
|
1
1
|
# wenmar-open
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A typed client for the [Wenmar Open](https://open.wenmarpro.com) API: VIN decoding and a year, make, model, trim and engine catalog for auto repair shops. The API needs no key and no account.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- No dependencies. It uses the platform's `fetch`.
|
|
6
|
+
- An [offline mode](#offline) that decodes from a data file with no network.
|
|
7
|
+
- Runs in Node 20 and later, browsers, Cloudflare Workers, Deno and Bun.
|
|
8
|
+
- Types are generated from the API's OpenAPI description.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install wenmar-open
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Decode a VIN
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { WenmarOpen } from "wenmar-open";
|
|
18
|
+
|
|
19
|
+
const open = new WenmarOpen();
|
|
20
|
+
|
|
21
|
+
const vehicle = await open.decodeVin("KM8K2CAB4PU001140");
|
|
22
|
+
console.log(vehicle.year, vehicle.make, vehicle.model, vehicle.trim);
|
|
23
|
+
// 2023 Hyundai Kona SE
|
|
24
|
+
console.log(vehicle.engine?.label, vehicle.drivetrain, vehicle.safety?.tpms);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Spaces, dashes and lowercase letters in the VIN are accepted. A field the data does not have is left out, so every field except `vin`, `valid`, `check_digit`, `manufacturer`, `plant` and `warnings` may be `undefined`.
|
|
28
|
+
|
|
29
|
+
A wrong check digit is not an error. The decode comes back with `valid: false` and a warning, because many genuine VINs from outside North America fail the check:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { WenmarOpen } from "wenmar-open";
|
|
33
|
+
|
|
34
|
+
const vehicle = await new WenmarOpen().decodeVin("KM8K2CAB0PU001140");
|
|
35
|
+
if (!vehicle.valid) {
|
|
36
|
+
for (const warning of vehicle.warnings) {
|
|
37
|
+
console.log(warning.code, warning.message, warning.suggestions);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If you know the model year, pass it: `open.decodeVin(vin, { year: 2023 })`.
|
|
43
|
+
|
|
44
|
+
## Errors
|
|
45
|
+
|
|
46
|
+
Every method throws a `WenmarOpenError`. It has the API's `code`, `message` and `details`, and the HTTP `status`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { WenmarOpen, WenmarOpenError } from "wenmar-open";
|
|
50
|
+
|
|
51
|
+
const open = new WenmarOpen();
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await open.decodeVin("KM8K2CAB4PUO01140"); // a letter O where a zero belongs
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!(error instanceof WenmarOpenError)) throw error;
|
|
57
|
+
if (error.code === "invalid_vin") {
|
|
58
|
+
console.log(error.message); // what is wrong with it
|
|
59
|
+
console.log(error.details.suggestions); // VINs you may have meant
|
|
60
|
+
} else if (error.code === "not_found") {
|
|
61
|
+
console.log("No manufacturer is registered for this VIN.");
|
|
62
|
+
} else if (error.code === "rate_limited" || error.code === "unavailable") {
|
|
63
|
+
console.log(`Try again in ${error.retryAfter ?? 60} seconds.`);
|
|
64
|
+
} else {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| `code` | `status` | Meaning |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `invalid_vin` | 400 | Wrong length or characters. `details.suggestions` lists likely corrections. |
|
|
73
|
+
| `validation_failed` | 400 | A parameter is missing or wrong. `details.field` names it. |
|
|
74
|
+
| `not_found` | 404 | No manufacturer for the VIN, or no vehicle with that id. |
|
|
75
|
+
| `rate_limited` | 429 | Over 600 requests a minute from one address. `retryAfter` is in seconds. |
|
|
76
|
+
| `unavailable` | 503 | The server is busy. `retryAfter` is in seconds. |
|
|
77
|
+
| `internal_error` | 500 | Something went wrong on the server. |
|
|
78
|
+
| `timeout` | none | No complete answer within `timeoutMs`. |
|
|
79
|
+
| `aborted` | none | Your `signal` was aborted. |
|
|
80
|
+
| `network` | none | The request could not be sent. `cause` has the reason. |
|
|
81
|
+
| `bad_response` | any | Something answered, but not the API: a proxy's error page, a sign-in page. |
|
|
82
|
+
|
|
83
|
+
The API may add codes. Treat a code you do not know as a failure.
|
|
84
|
+
|
|
85
|
+
The client does not retry. If you retry, do it only for `rate_limited`, `unavailable`, `timeout` and `network`, and wait `retryAfter` seconds when it is set.
|
|
86
|
+
|
|
87
|
+
## Decode many VINs
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { WenmarOpen, isBatchError } from "wenmar-open";
|
|
91
|
+
|
|
92
|
+
const open = new WenmarOpen();
|
|
93
|
+
const vins = ["KM8K2CAB4PU001140", "1HGCM82633A004352", "NOT-A-VIN"];
|
|
94
|
+
|
|
95
|
+
const items = await open.decodeVins(vins);
|
|
96
|
+
items.forEach((item, index) => {
|
|
97
|
+
if (isBatchError(item)) {
|
|
98
|
+
console.log(vins[index], "failed:", item.error.code);
|
|
99
|
+
} else {
|
|
100
|
+
console.log(item.vin, item.year, item.make, item.model);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A batch holds at most 50 VINs (`MAX_BATCH`). The answer has one item per VIN, in the order sent. One bad VIN does not fail the batch.
|
|
106
|
+
|
|
107
|
+
## Choose a vehicle without a VIN
|
|
108
|
+
|
|
109
|
+
Each step offers only what is valid for the steps before it.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { WenmarOpen } from "wenmar-open";
|
|
113
|
+
|
|
114
|
+
const open = new WenmarOpen();
|
|
115
|
+
|
|
116
|
+
const years = await open.years(); // newest first
|
|
117
|
+
const makes = await open.makes({ year: 2019 }); // popular makes first
|
|
118
|
+
const models = await open.models({ make: "honda", year: 2019 });
|
|
119
|
+
const trims = await open.submodels({ make: "honda", model: "civic", year: 2019 });
|
|
120
|
+
const engines = await open.engines({ make: "honda", model: "civic", year: 2019, submodel: "si" });
|
|
121
|
+
|
|
122
|
+
console.log(years[0], makes[0]?.name, models[0]?.name, trims[0]?.name, engines[0]?.label);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`make` and `model` take a name, an alias or an id: `Chevrolet`, `chevy` and `chevrolet` are the same make. `term` filters any step by what someone has typed so far, for autocomplete: `open.makes({ term: "che" })`.
|
|
126
|
+
|
|
127
|
+
Free text works too, and every vehicle has an id you can store:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { WenmarOpen } from "wenmar-open";
|
|
131
|
+
|
|
132
|
+
const open = new WenmarOpen();
|
|
133
|
+
|
|
134
|
+
const [best] = await open.search({ q: "2019 civic si" });
|
|
135
|
+
console.log(best?.id, best?.summary);
|
|
136
|
+
|
|
137
|
+
const same = await open.vehicle("2019_honda_civic_si");
|
|
138
|
+
console.log(same.summary);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A decoded VIN carries the same ids in `catalog`, so a VIN can fill in a vehicle form: `vehicle.catalog?.entry.id`.
|
|
142
|
+
|
|
143
|
+
## Options
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { WenmarOpen } from "wenmar-open";
|
|
147
|
+
|
|
148
|
+
const open = new WenmarOpen({
|
|
149
|
+
baseUrl: "http://localhost:3000", // your own server; default https://open.wenmarpro.com
|
|
150
|
+
timeoutMs: 5_000, // default 10,000; 0 for no limit
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Per request: a time limit, and a signal to cancel with.
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const makes = await open.makes({ term: "ho" }, { signal: controller.signal, timeoutMs: 2_000 });
|
|
156
|
+
console.log(makes.length);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`fetch` replaces the platform's `fetch`, for a Workers service binding or a test:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { WenmarOpen } from "wenmar-open";
|
|
163
|
+
import type { FetchLike } from "wenmar-open";
|
|
164
|
+
|
|
165
|
+
const logged: FetchLike = async (url, init) => {
|
|
166
|
+
console.log(init.method, url);
|
|
167
|
+
return fetch(url, init);
|
|
168
|
+
};
|
|
169
|
+
const open = new WenmarOpen({ fetch: logged });
|
|
170
|
+
console.log((await open.meta()).data_version);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Caching and limits
|
|
174
|
+
|
|
175
|
+
Answers change only when the data does, about once a month. `open.meta()` returns the `data_version`; anything you cache can be kept until it changes. In a browser the HTTP cache already does this.
|
|
176
|
+
|
|
177
|
+
One address may make 600 requests a minute. A server that calls the API for many users shares one address, so cache catalog answers there. To decode a list of VINs, use `decodeVins`: a batch of 50 counts as one request.
|
|
178
|
+
|
|
179
|
+
## Offline
|
|
180
|
+
|
|
181
|
+
`wenmar-open/offline` answers the same questions with no network. It runs the same decoder the API runs, compiled to WebAssembly and shipped inside this package, over a Wenmar Open data file: a plain SQLite database of about 167 MB. The methods, their answers and the error codes are those of `WenmarOpen`, with three more codes for the data ([below](#switching-between-the-api-and-the-data-file)), so code written for one works with the other.
|
|
182
|
+
|
|
183
|
+
The hosted client does not load any of this. An application that imports only `wenmar-open` gets the small client and nothing else.
|
|
184
|
+
|
|
185
|
+
### In Node
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm install wenmar-open wenmar-open-data
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`wenmar-open-data` is the data file as an npm package. It is 49 MB to download and 167 MB on disk, and a new version is published each month.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { WenmarOpenError } from "wenmar-open/offline";
|
|
195
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
196
|
+
|
|
197
|
+
const open = await openOffline();
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const vehicle = await open.decodeVin("KM8K2CAB4PU001140");
|
|
201
|
+
console.log(vehicle.year, vehicle.make, vehicle.model);
|
|
202
|
+
console.log(await open.models({ make: "honda", year: 2019 }));
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof WenmarOpenError) console.error(error.code, error.message);
|
|
205
|
+
else throw error;
|
|
206
|
+
} finally {
|
|
207
|
+
open.close();
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`openOffline()` uses Node's own SQLite, so it needs Node 22.16 or later. It opens the file read-only and checks it before it returns. To read a file from somewhere else, such as one downloaded with `wenmar-open data pull`, pass its path:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
215
|
+
|
|
216
|
+
const open = await openOffline({ path: "/var/lib/wenmar-open/wenmar-open-2026.09.sqlite3" });
|
|
217
|
+
console.log((await open.meta()).data_version);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
On Node 20, or to use a database you opened yourself, give the client a store. `syncStore` takes a `DatabaseSync` of `node:sqlite` or a database of `better-sqlite3`:
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
import Database from "better-sqlite3";
|
|
224
|
+
import { path } from "wenmar-open-data";
|
|
225
|
+
import { WenmarOpenOffline, syncStore } from "wenmar-open/offline";
|
|
226
|
+
|
|
227
|
+
const database = new Database(path, { readonly: true });
|
|
228
|
+
const open = new WenmarOpenOffline({ store: syncStore(database) });
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Switching between the API and the data file
|
|
232
|
+
|
|
233
|
+
Both clients have the same methods, so the choice can be one line:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { WenmarOpen } from "wenmar-open";
|
|
237
|
+
import type { VinDecode } from "wenmar-open";
|
|
238
|
+
import type { WenmarOpenOffline } from "wenmar-open/offline";
|
|
239
|
+
import { openOffline } from "wenmar-open/offline/node";
|
|
240
|
+
|
|
241
|
+
async function client(offline: boolean): Promise<WenmarOpen | WenmarOpenOffline> {
|
|
242
|
+
return offline ? openOffline() : new WenmarOpen();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const open = await client(true);
|
|
246
|
+
const vehicle: VinDecode = await open.decodeVin("KM8K2CAB4PU001140");
|
|
247
|
+
console.log(vehicle.catalog?.vehicle_id);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
What differs offline:
|
|
251
|
+
|
|
252
|
+
- `error.status` is always `undefined`: there is no HTTP answer. Branch on `error.code`.
|
|
253
|
+
- Three more codes: `no_data` (no data file where one was looked for), `data_invalid` (the database is not a data file this version reads) and `store_error` (the database failed while it was read, including while it was first opened, so a retry may succeed; `data_invalid` is for rows that were read and are not a data file, and for a schema version this client does not read).
|
|
254
|
+
- `search` reads the text as a year, a make, a model and a submodel, in that order. The API also finds a model from its words in any order, with an index the data file does not have. `search({ q: "civic type r" })` finds the Civic Type R in both; `search({ q: "type r" })` finds it only through the API.
|
|
255
|
+
- `meta().server_version` is the version of this package.
|
|
256
|
+
- `timeoutMs` and `signal` are checked each time the client goes back to the database, not while the database is working. There is no time limit unless you set one.
|
|
257
|
+
- The current year, which bounds the model years a VIN can have, comes from the device's clock. Pass `currentYear` to set it.
|
|
258
|
+
|
|
259
|
+
### In Cloudflare Workers
|
|
260
|
+
|
|
261
|
+
A Worker cannot carry a 167 MB file. Import the data into a D1 database once (see the [`wenmar-open-data` README](https://www.npmjs.com/package/wenmar-open-data)), or use the hosted API.
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
import { WenmarOpenOffline, d1Store } from "wenmar-open/offline";
|
|
265
|
+
import wasm from "wenmar-open/offline.wasm";
|
|
266
|
+
|
|
267
|
+
let open;
|
|
268
|
+
|
|
269
|
+
export default {
|
|
270
|
+
async fetch(request, env) {
|
|
271
|
+
open ??= new WenmarOpenOffline({ store: d1Store(env.DB), wasm });
|
|
272
|
+
const vin = new URL(request.url).pathname.slice(1);
|
|
273
|
+
return Response.json(await open.decodeVin(vin));
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Workers do not compile WebAssembly from bytes, so the module is imported as a file and passed as `wasm`. Everywhere else the option is left out.
|
|
279
|
+
|
|
280
|
+
A decode reads D1 6 or 7 times and runs 10 to 15 statements, each of which counts as one D1 query. The first question an isolate answers also reads about 12,000 rows of makes, once. `years()` reads every model-year row of the catalog, about 490,000, on every call: keep its answer.
|
|
281
|
+
|
|
282
|
+
### A store of your own
|
|
283
|
+
|
|
284
|
+
Anything that can run a `SELECT` on the data file can be a store: an object with one method, which is given a list of statements and returns the rows of each, in order, as arrays of values. It may return them at once or as a promise.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { WenmarOpenOffline } from "wenmar-open/offline";
|
|
288
|
+
import type { Statement, Store } from "wenmar-open/offline";
|
|
289
|
+
|
|
290
|
+
declare function run(sql: string, params: readonly (string | number | null)[]): Promise<unknown[][]>;
|
|
291
|
+
|
|
292
|
+
const store: Store = {
|
|
293
|
+
query: (statements: readonly Statement[]) => Promise.all(statements.map(({ sql, params }) => run(sql, params))),
|
|
294
|
+
};
|
|
295
|
+
export const open = new WenmarOpenOffline({ store });
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Rows must be arrays, not objects keyed by column name: two columns of one statement may share a name. Integers may be numbers or BigInts.
|
|
299
|
+
|
|
300
|
+
### Data versions
|
|
301
|
+
|
|
302
|
+
A version of `wenmar-open` reads data files of one schema version, and `wenmar-open-data`'s major version is that schema version: `wenmar-open-data@3.202609.0` is the data of September 2026 in schema version 3. A file of another schema version is refused with `data_invalid` and a message naming both versions.
|
|
303
|
+
|
|
304
|
+
| `wenmar-open` | reads `wenmar-open-data` |
|
|
305
|
+
|---|---|
|
|
306
|
+
| 0.2 | 3.x |
|
|
307
|
+
|
|
308
|
+
### Size
|
|
309
|
+
|
|
310
|
+
The decoder is 450 kB of WebAssembly (163 kB compressed). The package holds it twice, as a file and as text inside a module, so the package is 1.1 MB unpacked. None of it is loaded by `import "wenmar-open"`.
|
|
311
|
+
|
|
312
|
+
In a browser the page's content security policy must allow WebAssembly (`'wasm-unsafe-eval'`). The data file is too large to send to a browser; use the hosted API there.
|
|
313
|
+
|
|
314
|
+
## CommonJS
|
|
315
|
+
|
|
316
|
+
The package is an ES module. `require("wenmar-open")` works in Node 20.19 and later and in Node 22.12 and later. In older Node 20 releases use `await import("wenmar-open")`.
|
|
317
|
+
|
|
318
|
+
## Types
|
|
319
|
+
|
|
320
|
+
Every response type is exported: `VinDecode`, `Entry`, `Make`, `Model`, `Submodel`, `EngineOption`, `Meta`, `BatchItem`, `ErrorBody`. The generated `paths`, `operations` and `components` types are exported as well.
|
|
321
|
+
|
|
322
|
+
The API only ever adds fields and endpoints. A response may carry fields this version's types do not name; they are passed through untouched.
|
|
323
|
+
|
|
324
|
+
## License
|
|
325
|
+
|
|
326
|
+
MIT. Vehicle data comes from NHTSA's vPIC and is described in the repository's [NOTICE.md](https://github.com/wenmar-pro/wenmar-open/blob/main/NOTICE.md).
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BatchItem, ErrorBody } from "./types.js";
|
|
2
|
+
/** The most VINs decoded in one batch. */
|
|
3
|
+
export declare const MAX_BATCH = 50;
|
|
4
|
+
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
5
|
+
export declare function isBatchError(item: BatchItem): item is ErrorBody;
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The most VINs decoded in one batch. */
|
|
2
|
+
export const MAX_BATCH = 50;
|
|
3
|
+
/** Whether an item of a batch is the error for its VIN and not a decode. */
|
|
4
|
+
export function isBatchError(item) {
|
|
5
|
+
const error = item.error;
|
|
6
|
+
return typeof error === "object" && error !== null && !Array.isArray(error);
|
|
7
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { BatchItem, EngineOption, EnginesQuery, Entry, Make, MakesQuery, Meta, Model, ModelsQuery, SearchQuery, Submodel, SubmodelsQuery, VinDecode, YearsQuery } from "./types.js";
|
|
2
|
+
/** The hosted API. */
|
|
3
|
+
export declare const DEFAULT_BASE_URL = "https://open.wenmarpro.com";
|
|
4
|
+
/** How long a request may take before it fails with `timeout`. */
|
|
5
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
6
|
+
/** What the client asks of a `fetch`: the part of the standard it uses. */
|
|
7
|
+
export interface FetchInit {
|
|
8
|
+
method: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
/** What the client reads of a response. */
|
|
14
|
+
export interface FetchResponse {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly headers: {
|
|
17
|
+
get(name: string): string | null;
|
|
18
|
+
};
|
|
19
|
+
text(): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
/** A `fetch`. The platform's own fits; so does a Workers service binding's. */
|
|
22
|
+
export type FetchLike = (url: string, init: FetchInit) => Promise<FetchResponse>;
|
|
23
|
+
export interface ClientOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Where the API is. Default `https://open.wenmarpro.com`. A path is kept,
|
|
26
|
+
* so `https://example.com/open` reaches `https://example.com/open/v1/...`.
|
|
27
|
+
*/
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Milliseconds a request may take, the body included. Default 10,000.
|
|
31
|
+
* `0` means no limit.
|
|
32
|
+
*/
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
/** Used instead of the platform's `fetch`. */
|
|
35
|
+
fetch?: FetchLike;
|
|
36
|
+
}
|
|
37
|
+
/** What every method accepts last. */
|
|
38
|
+
export interface RequestOptions {
|
|
39
|
+
/** Aborting it fails the request with the code `aborted`. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/** Overrides the client's `timeoutMs` for this request. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface DecodeOptions extends RequestOptions {
|
|
45
|
+
/** Use this model year instead of working it out from the VIN. */
|
|
46
|
+
year?: number;
|
|
47
|
+
}
|
|
48
|
+
/** A client for the Wenmar Open API. It keeps no state between requests. */
|
|
49
|
+
export declare class WenmarOpen {
|
|
50
|
+
#private;
|
|
51
|
+
readonly baseUrl: string;
|
|
52
|
+
readonly timeoutMs: number;
|
|
53
|
+
constructor(options?: ClientOptions);
|
|
54
|
+
/** Decode one VIN. Spaces, dashes and lowercase are accepted. */
|
|
55
|
+
decodeVin(vin: string, options?: DecodeOptions): Promise<VinDecode>;
|
|
56
|
+
/**
|
|
57
|
+
* Decode up to 50 VINs. The answer has one item per VIN, in order: a
|
|
58
|
+
* decode, or the error a single request for that VIN would have given.
|
|
59
|
+
* Tell them apart with `isBatchError`.
|
|
60
|
+
*/
|
|
61
|
+
decodeVins(vins: readonly string[], options?: RequestOptions): Promise<BatchItem[]>;
|
|
62
|
+
/** Model years, newest first. */
|
|
63
|
+
years(query?: YearsQuery, options?: RequestOptions): Promise<number[]>;
|
|
64
|
+
/** Makes, popular ones first and the rest by name. */
|
|
65
|
+
makes(query?: MakesQuery, options?: RequestOptions): Promise<Make[]>;
|
|
66
|
+
/** Models of a make, by name. An unknown make has no models. */
|
|
67
|
+
models(query: ModelsQuery, options?: RequestOptions): Promise<Model[]>;
|
|
68
|
+
/** Submodels of a model year: trims, or series where there are no trims. */
|
|
69
|
+
submodels(query: SubmodelsQuery, options?: RequestOptions): Promise<Submodel[]>;
|
|
70
|
+
/** The same as `submodels`, under the name most people use. */
|
|
71
|
+
trims(query: SubmodelsQuery, options?: RequestOptions): Promise<Submodel[]>;
|
|
72
|
+
/** Engines of a model year, in short form. */
|
|
73
|
+
engines(query: EnginesQuery, options?: RequestOptions): Promise<EngineOption[]>;
|
|
74
|
+
/** Catalog entries for free text such as `2019 civic si`, best first. */
|
|
75
|
+
search(query: SearchQuery, options?: RequestOptions): Promise<Entry[]>;
|
|
76
|
+
/** One catalog entry by its id, such as `2019_honda_civic_si`. */
|
|
77
|
+
vehicle(id: string, options?: RequestOptions): Promise<Entry>;
|
|
78
|
+
/** The data version, the vPIC release it came from, and the build time. */
|
|
79
|
+
meta(options?: RequestOptions): Promise<Meta>;
|
|
80
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { MAX_BATCH } from "./batch.js";
|
|
2
|
+
import { WenmarOpenError, parseRetryAfter } from "./errors.js";
|
|
3
|
+
/** The hosted API. */
|
|
4
|
+
export const DEFAULT_BASE_URL = "https://open.wenmarpro.com";
|
|
5
|
+
/** How long a request may take before it fails with `timeout`. */
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
7
|
+
function queryString(query) {
|
|
8
|
+
const params = new URLSearchParams();
|
|
9
|
+
for (const [name, value] of Object.entries((query ?? {}))) {
|
|
10
|
+
if (value === undefined || value === null || value === "")
|
|
11
|
+
continue;
|
|
12
|
+
params.append(name, String(value));
|
|
13
|
+
}
|
|
14
|
+
const text = params.toString();
|
|
15
|
+
return text === "" ? "" : `?${text}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One path segment. A URL treats `.` and `..` as directions, not names, even
|
|
19
|
+
* when they are percent-encoded, so they are refused here: sent, they would
|
|
20
|
+
* reach a different address than the one meant.
|
|
21
|
+
*/
|
|
22
|
+
function segment(field, value) {
|
|
23
|
+
const text = String(value).trim();
|
|
24
|
+
if (text === "" || text === "." || text === "..") {
|
|
25
|
+
throw new WenmarOpenError({
|
|
26
|
+
code: "validation_failed",
|
|
27
|
+
message: `${field} is required`,
|
|
28
|
+
details: { field },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return encodeURIComponent(text);
|
|
32
|
+
}
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
/** A client for the Wenmar Open API. It keeps no state between requests. */
|
|
37
|
+
export class WenmarOpen {
|
|
38
|
+
baseUrl;
|
|
39
|
+
timeoutMs;
|
|
40
|
+
#fetch;
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
43
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
44
|
+
// The platform's fetch is looked up at each call, not kept.
|
|
45
|
+
this.#fetch = options.fetch ?? ((url, init) => fetch(url, init));
|
|
46
|
+
}
|
|
47
|
+
/** Decode one VIN. Spaces, dashes and lowercase are accepted. */
|
|
48
|
+
async decodeVin(vin, options = {}) {
|
|
49
|
+
const path = `/v1/vin/${segment("vin", vin)}${queryString({ year: options.year })}`;
|
|
50
|
+
return this.#request("GET", path, undefined, options);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Decode up to 50 VINs. The answer has one item per VIN, in order: a
|
|
54
|
+
* decode, or the error a single request for that VIN would have given.
|
|
55
|
+
* Tell them apart with `isBatchError`.
|
|
56
|
+
*/
|
|
57
|
+
async decodeVins(vins, options = {}) {
|
|
58
|
+
if (vins.length > MAX_BATCH) {
|
|
59
|
+
throw new WenmarOpenError({
|
|
60
|
+
code: "validation_failed",
|
|
61
|
+
message: `a batch holds at most ${MAX_BATCH} VINs`,
|
|
62
|
+
details: { field: "vins", max: MAX_BATCH, received: vins.length },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return this.#request("POST", "/v1/vin/batch", { vins }, options);
|
|
66
|
+
}
|
|
67
|
+
/** Model years, newest first. */
|
|
68
|
+
async years(query = {}, options = {}) {
|
|
69
|
+
return this.#request("GET", `/v1/vehicles/years${queryString(query)}`, undefined, options);
|
|
70
|
+
}
|
|
71
|
+
/** Makes, popular ones first and the rest by name. */
|
|
72
|
+
async makes(query = {}, options = {}) {
|
|
73
|
+
return this.#request("GET", `/v1/vehicles/makes${queryString(query)}`, undefined, options);
|
|
74
|
+
}
|
|
75
|
+
/** Models of a make, by name. An unknown make has no models. */
|
|
76
|
+
async models(query, options = {}) {
|
|
77
|
+
return this.#request("GET", `/v1/vehicles/models${queryString(query)}`, undefined, options);
|
|
78
|
+
}
|
|
79
|
+
/** Submodels of a model year: trims, or series where there are no trims. */
|
|
80
|
+
async submodels(query, options = {}) {
|
|
81
|
+
return this.#request("GET", `/v1/vehicles/submodels${queryString(query)}`, undefined, options);
|
|
82
|
+
}
|
|
83
|
+
/** The same as `submodels`, under the name most people use. */
|
|
84
|
+
async trims(query, options = {}) {
|
|
85
|
+
return this.#request("GET", `/v1/vehicles/trims${queryString(query)}`, undefined, options);
|
|
86
|
+
}
|
|
87
|
+
/** Engines of a model year, in short form. */
|
|
88
|
+
async engines(query, options = {}) {
|
|
89
|
+
return this.#request("GET", `/v1/vehicles/engines${queryString(query)}`, undefined, options);
|
|
90
|
+
}
|
|
91
|
+
/** Catalog entries for free text such as `2019 civic si`, best first. */
|
|
92
|
+
async search(query, options = {}) {
|
|
93
|
+
return this.#request("GET", `/v1/vehicles/search${queryString(query)}`, undefined, options);
|
|
94
|
+
}
|
|
95
|
+
/** One catalog entry by its id, such as `2019_honda_civic_si`. */
|
|
96
|
+
async vehicle(id, options = {}) {
|
|
97
|
+
return this.#request("GET", `/v1/vehicles/${segment("id", id)}`, undefined, options);
|
|
98
|
+
}
|
|
99
|
+
/** The data version, the vPIC release it came from, and the build time. */
|
|
100
|
+
async meta(options = {}) {
|
|
101
|
+
return this.#request("GET", "/v1/meta", undefined, options);
|
|
102
|
+
}
|
|
103
|
+
async #request(method, path, body, options) {
|
|
104
|
+
const caller = options.signal;
|
|
105
|
+
if (caller?.aborted) {
|
|
106
|
+
throw new WenmarOpenError({ code: "aborted", message: "The request was aborted.", cause: caller.reason });
|
|
107
|
+
}
|
|
108
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let timedOut = false;
|
|
111
|
+
const timer = timeoutMs > 0
|
|
112
|
+
? setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
controller.abort();
|
|
115
|
+
}, timeoutMs)
|
|
116
|
+
: undefined;
|
|
117
|
+
const onAbort = () => controller.abort();
|
|
118
|
+
caller?.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
const headers = { Accept: "application/json" };
|
|
120
|
+
const init = { method, headers, signal: controller.signal };
|
|
121
|
+
if (body !== undefined) {
|
|
122
|
+
headers["Content-Type"] = "application/json";
|
|
123
|
+
init.body = JSON.stringify(body);
|
|
124
|
+
}
|
|
125
|
+
let status;
|
|
126
|
+
let retryAfter;
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
// Called as a plain function, never as a method of this object: some
|
|
130
|
+
// runtimes refuse a `fetch` whose `this` is not the global object.
|
|
131
|
+
const send = this.#fetch;
|
|
132
|
+
const response = await send(`${this.baseUrl}${path}`, init);
|
|
133
|
+
status = response.status;
|
|
134
|
+
retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
135
|
+
// The time limit covers the body: it is read before the timer stops.
|
|
136
|
+
text = await response.text();
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
if (timedOut) {
|
|
140
|
+
throw new WenmarOpenError({
|
|
141
|
+
code: "timeout",
|
|
142
|
+
message: `No answer within ${timeoutMs} ms.`,
|
|
143
|
+
details: { timeout_ms: timeoutMs },
|
|
144
|
+
cause,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (caller?.aborted) {
|
|
148
|
+
throw new WenmarOpenError({ code: "aborted", message: "The request was aborted.", cause });
|
|
149
|
+
}
|
|
150
|
+
throw new WenmarOpenError({
|
|
151
|
+
code: "network",
|
|
152
|
+
message: `The API at ${this.baseUrl} could not be reached.`,
|
|
153
|
+
cause,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (timer !== undefined)
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
caller?.removeEventListener("abort", onAbort);
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
let isJson = true;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(text);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
isJson = false;
|
|
168
|
+
}
|
|
169
|
+
if (status >= 200 && status < 300) {
|
|
170
|
+
if (!isJson) {
|
|
171
|
+
throw new WenmarOpenError({
|
|
172
|
+
code: "bad_response",
|
|
173
|
+
message: `The answer from ${this.baseUrl} is not JSON. Something other than the API may have answered.`,
|
|
174
|
+
status,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
const error = isRecord(parsed) ? parsed["error"] : undefined;
|
|
180
|
+
if (isRecord(error) && typeof error["code"] === "string" && typeof error["message"] === "string") {
|
|
181
|
+
const details = isRecord(error["details"]) ? error["details"] : {};
|
|
182
|
+
// The header is what the API promises. The body's copy is used when a
|
|
183
|
+
// proxy in between dropped the header.
|
|
184
|
+
const inBody = details["retry_after"];
|
|
185
|
+
throw new WenmarOpenError({
|
|
186
|
+
code: error["code"],
|
|
187
|
+
message: error["message"],
|
|
188
|
+
details,
|
|
189
|
+
status,
|
|
190
|
+
retryAfter: retryAfter ?? (typeof inBody === "number" ? inBody : undefined),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
throw new WenmarOpenError({
|
|
194
|
+
code: "bad_response",
|
|
195
|
+
message: `The server answered ${status} without the API's error body. A proxy in front of the API may have answered.`,
|
|
196
|
+
status,
|
|
197
|
+
retryAfter,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|