bolbol-sdk 1.0.1 → 1.0.2
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/README.md +393 -393
- package/package.json +28 -28
package/README.md
CHANGED
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
# bolbol-sdk
|
|
2
|
-
|
|
3
|
-
The typed client for the Bolbol API. Every app that talks to the backend - the
|
|
4
|
-
Console (Next.js) and the Academy CLI (Node) today - goes through this package
|
|
5
|
-
instead of calling `fetch` itself, so the shape of every request and answer is
|
|
6
|
-
declared exactly once.
|
|
7
|
-
|
|
8
|
-
```ts
|
|
9
|
-
import { BolbolServer, HTTPError } from "bolbol-sdk";
|
|
10
|
-
|
|
11
|
-
const bolbol = new BolbolServer({
|
|
12
|
-
baseUrl: "https://bolbol.io/api",
|
|
13
|
-
getToken: () => readSessionToken(),
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const response = await bolbol.auth.user.login({ email, password });
|
|
17
|
-
console.log(response.result.user.name);
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
- [Layout](#layout)
|
|
21
|
-
- [Using it](#using-it)
|
|
22
|
-
- [The rules](#the-rules)
|
|
23
|
-
- [1. One scope per backend controller folder](#1-one-scope-per-backend-controller-folder)
|
|
24
|
-
- [2. One method per controller](#2-one-method-per-controller)
|
|
25
|
-
- [3. Types: one per file, named by what they are](#3-types-one-per-file-named-by-what-they-are)
|
|
26
|
-
- [Translatable columns: `TranslationType`](#translatable-columns-translationtype)
|
|
27
|
-
- [4. Wire format](#4-wire-format)
|
|
28
|
-
- [Adding or changing an endpoint](#adding-or-changing-an-endpoint)
|
|
29
|
-
- [Checking the SDK against the API](#checking-the-sdk-against-the-api)
|
|
30
|
-
- [Scripts](#scripts)
|
|
31
|
-
|
|
32
|
-
## Layout
|
|
33
|
-
|
|
34
|
-
```
|
|
35
|
-
src/
|
|
36
|
-
├── BolbolServer.ts the entry point: builds the HTTPClient and one instance of every scope
|
|
37
|
-
├── HTTPClient.ts the only place that calls fetch
|
|
38
|
-
├── HTTPError.ts what every failed call throws
|
|
39
|
-
├── index.ts generated - re-exports everything below
|
|
40
|
-
├── scope/
|
|
41
|
-
│ ├── BolbolAuthScope.ts mirrors Bolbol API src/controller/auth
|
|
42
|
-
│ ├── BolbolAcademyScope.ts mirrors Bolbol API src/controller/academy
|
|
43
|
-
│ └── ... one file per backend controller folder
|
|
44
|
-
└── type/
|
|
45
|
-
├── UserRowType.ts a table row
|
|
46
|
-
├── AuthLoginRequestType.ts what one endpoint takes
|
|
47
|
-
├── AuthLoginResponseType.ts what one endpoint answers
|
|
48
|
-
├── RequestStatusEnum.ts a database enum
|
|
49
|
-
├── BolbolResponseType.ts a shared shape
|
|
50
|
-
└── ...
|
|
51
|
-
scripts/
|
|
52
|
-
├── generate-index.mjs writes src/index.ts
|
|
53
|
-
test/
|
|
54
|
-
└── api-contract.test.mjs checks every backend controller against its SDK method
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Using it
|
|
58
|
-
|
|
59
|
-
### Creating the server
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
new BolbolServer({
|
|
63
|
-
baseUrl: "https://bolbol.io/api", // API base, mount path included, no trailing slash
|
|
64
|
-
getToken: () => token, // sync or async; read per request
|
|
65
|
-
timeoutMs: 30000, // optional
|
|
66
|
-
init: { credentials: "include", cache: "no-store" }, // optional extra fetch options
|
|
67
|
-
});
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`getToken` is asked for the session token on every call, so a
|
|
71
|
-
browser app can read its cookie and a server can read its request's cookie with
|
|
72
|
-
the same class. Create one `BolbolServer` per token source and reuse it.
|
|
73
|
-
|
|
74
|
-
### Calling an endpoint
|
|
75
|
-
|
|
76
|
-
Scopes hang off the server, one per backend controller folder, and group their
|
|
77
|
-
methods by noun:
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
await bolbol.teacher.certificate.list(teacherUid);
|
|
81
|
-
await bolbol.teacher.certificate.create(teacherUid, { name, issuer, issue_date });
|
|
82
|
-
await bolbol.academy.asset.get(academyUid, assetUid);
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Every method resolves to the API's envelope, `BolbolResponseType<T>`:
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
const response = await bolbol.auth.user.me();
|
|
89
|
-
response.result.user; // the controller's own output
|
|
90
|
-
response.duration; // plus the envelope metadata: id, code, status, started_at, duration
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### Errors
|
|
94
|
-
|
|
95
|
-
Every call that does not get a 2xx answer throws `HTTPError`:
|
|
96
|
-
|
|
97
|
-
| Field | Meaning |
|
|
98
|
-
| --- | --- |
|
|
99
|
-
| `code` | The HTTP status, or `0` (`HTTPError.UNREACHABLE`) when the API could not be reached at all |
|
|
100
|
-
| `message` | The API's own explanation, already localized, or an English fallback |
|
|
101
|
-
| `lang` | The language of `message`, when the API said |
|
|
102
|
-
| `errors` | For a validation 400: one `{ path, message }` per failed rule |
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
try
|
|
106
|
-
{
|
|
107
|
-
await bolbol.auth.user.login(input);
|
|
108
|
-
}
|
|
109
|
-
catch (error)
|
|
110
|
-
{
|
|
111
|
-
if (error instanceof HTTPError && error.code === 401)
|
|
112
|
-
showWrongPassword(error.message);
|
|
113
|
-
else
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## The rules
|
|
119
|
-
|
|
120
|
-
These keep the SDK a faithful, predictable mirror of the backend. Follow them
|
|
121
|
-
for every change; `npm test` enforces them.
|
|
122
|
-
|
|
123
|
-
### 1. One scope per backend controller folder
|
|
124
|
-
|
|
125
|
-
- Every folder under `Bolbol API/src/controller/<folder>` has exactly one scope
|
|
126
|
-
file, `src/scope/Bolbol{Folder}Scope.ts`, where `{Folder}` is the folder name
|
|
127
|
-
in PascalCase: `auth` → `BolbolAuthScope`, `user_file` → `BolbolUserFileScope`.
|
|
128
|
-
- `BolbolServer` creates **one instance of each scope** and exposes it as a
|
|
129
|
-
public readonly property named after the folder in camelCase:
|
|
130
|
-
`bolbol.auth`, `bolbol.userFile`, `bolbol.academy`.
|
|
131
|
-
- A scope takes the shared `HTTPClient` in its constructor and nothing else. It
|
|
132
|
-
never calls `fetch` and never reads configuration of its own.
|
|
133
|
-
- There are no scopes for anything that is not a controller folder. Helpers
|
|
134
|
-
that are not endpoints (resolving a static asset path) live on `BolbolServer`.
|
|
135
|
-
|
|
136
|
-
### 2. One method per controller
|
|
137
|
-
|
|
138
|
-
Every controller file in the folder gets exactly one method on its scope - no
|
|
139
|
-
more, no fewer.
|
|
140
|
-
|
|
141
|
-
**Name and grouping.** Methods are grouped into one object per noun, with a
|
|
142
|
-
short verb as the key, so call sites read as `scope.noun.verb(...)`:
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
public readonly certificate = {
|
|
146
|
-
list: (teacher_uid: string) => ...,
|
|
147
|
-
create: (teacher_uid: string, request: TeacherCertificateCreateRequestType) => ...,
|
|
148
|
-
update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType) => ...,
|
|
149
|
-
delete: (teacher_uid: string, certificate_id: number) => ...,
|
|
150
|
-
};
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Nest a noun inside another when the URL nests it (`academy.asset.version.publish`).
|
|
154
|
-
|
|
155
|
-
**The marker comment.** The line right above every method is exactly:
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
// {METHOD} {path exactly as in the controller's info()} - {API name}
|
|
159
|
-
// POST /auth/cli-login/:request_token/approve - AuthCLILoginApprove
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
`npm test` reads these markers to find the method for each controller.
|
|
163
|
-
|
|
164
|
-
**The API name** identifies an endpoint everywhere in the SDK. It is every
|
|
165
|
-
folder between `src/controller/` and the file, each in PascalCase, followed by
|
|
166
|
-
the file name without `Controller`. The method lives on the scope of the
|
|
167
|
-
top-level folder:
|
|
168
|
-
|
|
169
|
-
| Controller file | API name |
|
|
170
|
-
| --- | --- |
|
|
171
|
-
| `auth/LoginController.ts` | `AuthLogin` |
|
|
172
|
-
| `academy/AssetCreateController.ts` | `AcademyAssetCreate` |
|
|
173
|
-
| `user_file/UploadController.ts` | `UserFileUpload` |
|
|
174
|
-
| `teacher/UpdatePhotoController.ts` | `TeacherUpdatePhoto` |
|
|
175
|
-
| `bolbol/teacher_exam/CountPendingController.ts` | `BolbolTeacherExamCountPending` (on `BolbolBolbolScope`) |
|
|
176
|
-
|
|
177
|
-
**The signature mirrors the controller:**
|
|
178
|
-
|
|
179
|
-
- **Path parameters** come first, as positional arguments in the order they
|
|
180
|
-
appear in the path, named exactly like the path segment (`teacher_uid`,
|
|
181
|
-
`certificate_id`) and typed like the controller's parameter schema
|
|
182
|
-
(`string` for a uid or token, `number` for a numeric id).
|
|
183
|
-
- **Then one `request` argument** of type `{API name}RequestType`, when the
|
|
184
|
-
controller takes input beyond the path: its body schema for POST / PUT /
|
|
185
|
-
PATCH / DELETE, its query schema for GET. When every field of a **query** is
|
|
186
|
-
optional the argument defaults to `{}`. A body never defaults, even when all
|
|
187
|
-
its fields are optional: some updates clear every field they are not sent, so
|
|
188
|
-
sending one must be deliberate.
|
|
189
|
-
- **The return type** is `Promise<BolbolResponseType<{API name}ResponseType>>`,
|
|
190
|
-
where the ResponseType is exactly what the controller's `handle()` returns.
|
|
191
|
-
- The HTTP method and path are the controller's own. Interpolate path
|
|
192
|
-
parameters with `encodeURIComponent` for strings.
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
// PUT /teacher/:teacher_uid/certificate/:certificate_id - TeacherCertificateUpdate
|
|
196
|
-
update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType): Promise<BolbolResponseType<TeacherCertificateUpdateResponseType>> =>
|
|
197
|
-
this.http.put(`/teacher/${encodeURIComponent(teacher_uid)}/certificate/${certificate_id}`, { body: request }),
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
Convenience that the backend does not have - reading a browser `File` into
|
|
201
|
-
base64 before an upload, turning a path into a URL - may wrap the call, but the
|
|
202
|
-
arguments that reach the wire must still be exactly the controller's.
|
|
203
|
-
|
|
204
|
-
### 3. Types: one per file, named by what they are
|
|
205
|
-
|
|
206
|
-
Every type the SDK exposes lives in `src/type/`, **one exported type per file,
|
|
207
|
-
file named exactly after the type**. No barrels, no `types.ts`, no type declared
|
|
208
|
-
inside a scope file. The suffix says what kind of type it is, and there are only
|
|
209
|
-
five:
|
|
210
|
-
|
|
211
|
-
| Suffix | What it is | Named after | Example |
|
|
212
|
-
| --- | --- | --- | --- |
|
|
213
|
-
| `RowType` | One row of a database table, as the API sends it (the backend's `Public*` projection when it has one - fields that never leave the backend, like a numeric `id` behind a `uid`, are left out) | The table, in PascalCase | `UserRowType`, `TeacherCertificateRowType`, `ItemVersionRowType` |
|
|
214
|
-
| `RequestType` | The input of one endpoint - its body (or query, for a GET) | The API name | `AuthLoginRequestType`, `AcademyAssetCreateRequestType` |
|
|
215
|
-
| `ResponseType` | The `result` of one endpoint | The API name | `AuthLoginResponseType`, `AcademyAssetListResponseType` |
|
|
216
|
-
| `Enum` | A database or API enum, as a TypeScript `enum`. Every member is PascalCase and its value is exactly its name (`PastDue = "PastDue"`) | The backend enum without its `Database_` prefix, in PascalCase | `RequestStatusEnum` (`Database_Request_Status`), `CLILoginRequestStatusEnum` |
|
|
217
|
-
| `Type` | Anything else shared by several of the above: a JSON column's shape, a generic wrapper | What it describes | `BolbolResponseType<T>`, `ListPageType<T>`, `UserSettingGeneralType` |
|
|
218
|
-
|
|
219
|
-
Rules that make the table work:
|
|
220
|
-
|
|
221
|
-
- **Every endpoint has its own RequestType (if it takes input) and its own
|
|
222
|
-
ResponseType** - even when two endpoints answer the same shape. Then the
|
|
223
|
-
second one is an alias, not a copy:
|
|
224
|
-
|
|
225
|
-
```ts
|
|
226
|
-
// AcademyAssetGetResponseType.ts
|
|
227
|
-
import { AssetRowType } from "./AssetRowType";
|
|
228
|
-
|
|
229
|
-
export interface AcademyAssetGetResponseType
|
|
230
|
-
{
|
|
231
|
-
item: AssetRowType;
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
// AcademyAssetUpdateResponseType.ts
|
|
237
|
-
import { AcademyAssetGetResponseType } from "./AcademyAssetGetResponseType";
|
|
238
|
-
|
|
239
|
-
export type AcademyAssetUpdateResponseType = AcademyAssetGetResponseType;
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
This keeps "what does endpoint X take and answer" a single lookup, and lets
|
|
243
|
-
one endpoint change shape without dragging the others along.
|
|
244
|
-
- **Paged list requests extend `ListPagingType`**; paged list answers use
|
|
245
|
-
`ListPageType<Row>`, unpaged ones `ListType<Row>`.
|
|
246
|
-
- **Field names are the wire names** - snake_case, exactly as the backend's Zod
|
|
247
|
-
schema or projection spells them. Never camelCase a field.
|
|
248
|
-
- **Optional vs nullable follow the backend**: `.optional()` → `field?: T`,
|
|
249
|
-
`.nullable()` → `field: T | null`, `.nullish()` → `field?: T | null`.
|
|
250
|
-
- Dates travel as ISO strings, so they are `string` here, never `Date`.
|
|
251
|
-
- Each type file starts with a comment naming its source in the backend
|
|
252
|
-
(`// Mirrors PublicTeacher in Bolbol API src/service/teacher/TeacherPublic.ts`).
|
|
253
|
-
- **The Bolbol API imports these types too.** Its `toPublic*()` projections
|
|
254
|
-
return the SDK's RowTypes / Types, and the enums whose values go over the wire
|
|
255
|
-
(`UserPolicyEnum`, `RequestStatusEnum`, `ItemTypeEnum`, ...) exist ONLY here -
|
|
256
|
-
the backend's database columns are declared from them. So for those, this
|
|
257
|
-
package is the source of truth, not a mirror: a change here changes the
|
|
258
|
-
backend's database enum and its compile-time contract. Keep every enum's keys
|
|
259
|
-
equal to its values - the backend builds columns with `Object.keys(...)`.
|
|
260
|
-
|
|
261
|
-
### Translatable columns: `TranslationType`
|
|
262
|
-
|
|
263
|
-
A database column whose text is read in several languages travels with a
|
|
264
|
-
sibling `<column>_translations` field (the backend rule is
|
|
265
|
-
`Bolbol API/rules/Translation.md`). Its shape is one shared type:
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
// TranslationType.ts - keyed by LanguageCodeType, the 32 hard-coded language
|
|
269
|
-
// codes ("en", "fr", "fa", ...); a language with no translation is an absent key.
|
|
270
|
-
export type TranslationType = { [language_code in LanguageCodeType]?: string };
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
- **RowType:** the field sits directly below its source field and is always
|
|
274
|
-
present - the backend stores `{}` for "no translations", never `null`, even
|
|
275
|
-
when the source field is nullable:
|
|
276
|
-
|
|
277
|
-
```ts
|
|
278
|
-
name: string;
|
|
279
|
-
name_translations: TranslationType;
|
|
280
|
-
content: string | null;
|
|
281
|
-
content_translations: TranslationType;
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
The map only ever holds languages that have a value - never all languages
|
|
285
|
-
with blanks.
|
|
286
|
-
|
|
287
|
-
- **RequestType:** every Create / Update that takes the source field takes its
|
|
288
|
-
`<column>_translations` too, as a **patch of only the changed languages**, not
|
|
289
|
-
the whole map. Its type is the second shared type:
|
|
290
|
-
|
|
291
|
-
```ts
|
|
292
|
-
// TranslationPatchType.ts
|
|
293
|
-
// Mirrors translationSchema() in Bolbol API src/controller/TranslationSchema.ts.
|
|
294
|
-
// Only the languages that changed: a string sets that language, null removes
|
|
295
|
-
// it, a language not listed is left as stored.
|
|
296
|
-
export type TranslationPatchType = { [language_code in LanguageCodeType]?: string | null };
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
Optionality follows the Zod schema (`translationSchema(...).optional()` →
|
|
300
|
-
`name_translations?: TranslationPatchType`). Leave the field out when no
|
|
301
|
-
language changed.
|
|
302
|
-
- **Flattened detail columns (`academy.item_version.details_*`)** each get their
|
|
303
|
-
own sibling `_translations` column, same convention as every other
|
|
304
|
-
translation column. Plain translations use `TranslationType` /
|
|
305
|
-
`TranslationPatchType`; choice-keyed translations (e.g.
|
|
306
|
-
`details_multiplechoice_choices_translations`) use
|
|
307
|
-
`ChoiceTranslationType<TranslationType>` on the RowType and
|
|
308
|
-
`ChoiceTranslationType<TranslationPatchType | null>` on the RequestType
|
|
309
|
-
(`null` on a choice removes all its translations). Shape and rules:
|
|
310
|
-
`Bolbol API/rules/Translation.md`, "Translating `academy.item_version` detail
|
|
311
|
-
columns".
|
|
312
|
-
- Use `TranslationType` / `TranslationPatchType` by name. Never inline
|
|
313
|
-
`Record<string, string>` or `{ [k: string]: string }` in a RowType or
|
|
314
|
-
RequestType - one name is what lets every app find (and type-check) every
|
|
315
|
-
translatable field.
|
|
316
|
-
- Reading one: `row.name_translations[code] ?? row.name`. The SDK has no helper
|
|
317
|
-
for this on purpose (rule 1: no non-endpoint code outside `BolbolServer`); each
|
|
318
|
-
app wraps that one expression in its own picker.
|
|
319
|
-
|
|
320
|
-
### 4. Wire format
|
|
321
|
-
|
|
322
|
-
- The API reads the session token from the `b-token` header. `HTTPClient` adds
|
|
323
|
-
it to **every** request whenever `getToken` returns one - public endpoints
|
|
324
|
-
included - and scopes never set it by hand. There is no per-call auth flag on
|
|
325
|
-
purpose: a public endpoint treats a missing or stale token as a guest, while a
|
|
326
|
-
valid one personalises the answer (member-only items, the owner's private
|
|
327
|
-
files, the roster of an academy you own, errors in the user's language).
|
|
328
|
-
Sending the token only to endpoints that require sign-in would silently lose
|
|
329
|
-
all of that.
|
|
330
|
-
- Bodies are JSON. Query values are strings on the wire; `HTTPClient`
|
|
331
|
-
serializes numbers and booleans, repeats a key for an array, and drops
|
|
332
|
-
`null` / `undefined`.
|
|
333
|
-
- Endpoints that stream bytes instead of an envelope use `this.http.blob(...)`.
|
|
334
|
-
- Endpoints that take raw bytes instead of a JSON body (`UserFileContentUpload`)
|
|
335
|
-
have no body schema; their method takes a trailing `content: HTTPRawBody` (a
|
|
336
|
-
Blob, or a ReadableStream to count the bytes as they go) and sends
|
|
337
|
-
it as `{ raw: content }` (`Content-Type: application/octet-stream`, no
|
|
338
|
-
per-request timeout). In Node, pass `await fs.openAsBlob(path)` so a large
|
|
339
|
-
file streams from disk.
|
|
340
|
-
|
|
341
|
-
## Adding or changing an endpoint
|
|
342
|
-
|
|
343
|
-
1. Add or update the RowTypes / Enums the change needs in `src/type/`, and the
|
|
344
|
-
endpoint's RequestType / ResponseType, following rule 3.
|
|
345
|
-
2. Add or update its method on the folder's scope, with the marker comment,
|
|
346
|
-
following rule 2. A new controller folder means a new
|
|
347
|
-
`Bolbol{Folder}Scope.ts` and one more property on `BolbolServer`.
|
|
348
|
-
3. Bump the version and publish.
|
|
349
|
-
4. In the Bolbol API: bump `bolbol-sdk`, change the controller, and make its
|
|
350
|
-
projection return the new type - the compiler shows everything that no
|
|
351
|
-
longer matches.
|
|
352
|
-
5. `npm test` here (against the updated API) and `npm run build`.
|
|
353
|
-
6. Bump `bolbol-sdk` in the Console and the CLI and fix their callers.
|
|
354
|
-
|
|
355
|
-
## Checking the SDK against the API
|
|
356
|
-
|
|
357
|
-
```
|
|
358
|
-
npm test # expects the API at ../Bolbol API
|
|
359
|
-
BOLBOL_API_PATH=<path to Bolbol API> npm test
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
`test/api-contract.test.mjs` reads both code bases with the TypeScript compiler
|
|
363
|
-
(nothing runs, no server needed) and, for **every** backend controller, checks:
|
|
364
|
-
|
|
365
|
-
| # | Check |
|
|
366
|
-
| --- | --- |
|
|
367
|
-
| 1 | a scope method carries the controller's marker comment |
|
|
368
|
-
| 2 | it lives on `Bolbol{TopFolder}Scope` |
|
|
369
|
-
| 3 | the marker's path and the URL the method actually builds equal the controller's path |
|
|
370
|
-
| 4 | it calls `this.http.<method>` as the controller's `info()` says (`blob` for a streamed GET) |
|
|
371
|
-
| 5 | it takes `request: {API name}RequestType` exactly when the controller has a body (or, for a GET, a query) schema, sends it as `{ body: request }` / `{ query: request }`, and the RequestType's field names and optionality equal the Zod schema's |
|
|
372
|
-
| 6 | it returns `Promise<BolbolResponseType<{API name}ResponseType>>` and that type exists |
|
|
373
|
-
| 7 | its leading arguments are the path parameters - same names, path order, `number` / `string` as the parameter schema says |
|
|
374
|
-
|
|
375
|
-
plus: no SDK method without a controller, no duplicates, and every type file
|
|
376
|
-
exporting exactly one type named after the file, with one of the five suffixes.
|
|
377
|
-
|
|
378
|
-
What it cannot read is the shape of a controller's *output* (`handle()` builds
|
|
379
|
-
it in code), so a ResponseType's fields are still a review question - the
|
|
380
|
-
marker comment and the one-file-per-type rule make that quick to answer.
|
|
381
|
-
|
|
382
|
-
The parser is TypeScript 5's compiler API (dev dependency `typescript5`),
|
|
383
|
-
because TypeScript 7, which builds the SDK, has no stable JavaScript API.
|
|
384
|
-
|
|
385
|
-
## Scripts
|
|
386
|
-
|
|
387
|
-
| Script | What it does |
|
|
388
|
-
| --- | --- |
|
|
389
|
-
| `npm run build` | Regenerate `src/index.ts`, then compile to `dist/` |
|
|
390
|
-
| `npm run typecheck` | Regenerate `src/index.ts`, then type check without emitting |
|
|
391
|
-
| `npm test` | Check every backend controller against the SDK (see above) |
|
|
392
|
-
| `npm run index` | Regenerate `src/index.ts` only |
|
|
393
|
-
| `npm run clean` | Remove `dist/` |
|
|
1
|
+
# bolbol-sdk
|
|
2
|
+
|
|
3
|
+
The typed client for the Bolbol API. Every app that talks to the backend - the
|
|
4
|
+
Console (Next.js) and the Academy CLI (Node) today - goes through this package
|
|
5
|
+
instead of calling `fetch` itself, so the shape of every request and answer is
|
|
6
|
+
declared exactly once.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { BolbolServer, HTTPError } from "bolbol-sdk";
|
|
10
|
+
|
|
11
|
+
const bolbol = new BolbolServer({
|
|
12
|
+
baseUrl: "https://bolbol.io/api",
|
|
13
|
+
getToken: () => readSessionToken(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const response = await bolbol.auth.user.login({ email, password });
|
|
17
|
+
console.log(response.result.user.name);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- [Layout](#layout)
|
|
21
|
+
- [Using it](#using-it)
|
|
22
|
+
- [The rules](#the-rules)
|
|
23
|
+
- [1. One scope per backend controller folder](#1-one-scope-per-backend-controller-folder)
|
|
24
|
+
- [2. One method per controller](#2-one-method-per-controller)
|
|
25
|
+
- [3. Types: one per file, named by what they are](#3-types-one-per-file-named-by-what-they-are)
|
|
26
|
+
- [Translatable columns: `TranslationType`](#translatable-columns-translationtype)
|
|
27
|
+
- [4. Wire format](#4-wire-format)
|
|
28
|
+
- [Adding or changing an endpoint](#adding-or-changing-an-endpoint)
|
|
29
|
+
- [Checking the SDK against the API](#checking-the-sdk-against-the-api)
|
|
30
|
+
- [Scripts](#scripts)
|
|
31
|
+
|
|
32
|
+
## Layout
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
├── BolbolServer.ts the entry point: builds the HTTPClient and one instance of every scope
|
|
37
|
+
├── HTTPClient.ts the only place that calls fetch
|
|
38
|
+
├── HTTPError.ts what every failed call throws
|
|
39
|
+
├── index.ts generated - re-exports everything below
|
|
40
|
+
├── scope/
|
|
41
|
+
│ ├── BolbolAuthScope.ts mirrors Bolbol API src/controller/auth
|
|
42
|
+
│ ├── BolbolAcademyScope.ts mirrors Bolbol API src/controller/academy
|
|
43
|
+
│ └── ... one file per backend controller folder
|
|
44
|
+
└── type/
|
|
45
|
+
├── UserRowType.ts a table row
|
|
46
|
+
├── AuthLoginRequestType.ts what one endpoint takes
|
|
47
|
+
├── AuthLoginResponseType.ts what one endpoint answers
|
|
48
|
+
├── RequestStatusEnum.ts a database enum
|
|
49
|
+
├── BolbolResponseType.ts a shared shape
|
|
50
|
+
└── ...
|
|
51
|
+
scripts/
|
|
52
|
+
├── generate-index.mjs writes src/index.ts
|
|
53
|
+
test/
|
|
54
|
+
└── api-contract.test.mjs checks every backend controller against its SDK method
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Using it
|
|
58
|
+
|
|
59
|
+
### Creating the server
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
new BolbolServer({
|
|
63
|
+
baseUrl: "https://bolbol.io/api", // API base, mount path included, no trailing slash
|
|
64
|
+
getToken: () => token, // sync or async; read per request
|
|
65
|
+
timeoutMs: 30000, // optional
|
|
66
|
+
init: { credentials: "include", cache: "no-store" }, // optional extra fetch options
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`getToken` is asked for the session token on every call, so a
|
|
71
|
+
browser app can read its cookie and a server can read its request's cookie with
|
|
72
|
+
the same class. Create one `BolbolServer` per token source and reuse it.
|
|
73
|
+
|
|
74
|
+
### Calling an endpoint
|
|
75
|
+
|
|
76
|
+
Scopes hang off the server, one per backend controller folder, and group their
|
|
77
|
+
methods by noun:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
await bolbol.teacher.certificate.list(teacherUid);
|
|
81
|
+
await bolbol.teacher.certificate.create(teacherUid, { name, issuer, issue_date });
|
|
82
|
+
await bolbol.academy.asset.get(academyUid, assetUid);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every method resolves to the API's envelope, `BolbolResponseType<T>`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const response = await bolbol.auth.user.me();
|
|
89
|
+
response.result.user; // the controller's own output
|
|
90
|
+
response.duration; // plus the envelope metadata: id, code, status, started_at, duration
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Errors
|
|
94
|
+
|
|
95
|
+
Every call that does not get a 2xx answer throws `HTTPError`:
|
|
96
|
+
|
|
97
|
+
| Field | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `code` | The HTTP status, or `0` (`HTTPError.UNREACHABLE`) when the API could not be reached at all |
|
|
100
|
+
| `message` | The API's own explanation, already localized, or an English fallback |
|
|
101
|
+
| `lang` | The language of `message`, when the API said |
|
|
102
|
+
| `errors` | For a validation 400: one `{ path, message }` per failed rule |
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
try
|
|
106
|
+
{
|
|
107
|
+
await bolbol.auth.user.login(input);
|
|
108
|
+
}
|
|
109
|
+
catch (error)
|
|
110
|
+
{
|
|
111
|
+
if (error instanceof HTTPError && error.code === 401)
|
|
112
|
+
showWrongPassword(error.message);
|
|
113
|
+
else
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## The rules
|
|
119
|
+
|
|
120
|
+
These keep the SDK a faithful, predictable mirror of the backend. Follow them
|
|
121
|
+
for every change; `npm test` enforces them.
|
|
122
|
+
|
|
123
|
+
### 1. One scope per backend controller folder
|
|
124
|
+
|
|
125
|
+
- Every folder under `Bolbol API/src/controller/<folder>` has exactly one scope
|
|
126
|
+
file, `src/scope/Bolbol{Folder}Scope.ts`, where `{Folder}` is the folder name
|
|
127
|
+
in PascalCase: `auth` → `BolbolAuthScope`, `user_file` → `BolbolUserFileScope`.
|
|
128
|
+
- `BolbolServer` creates **one instance of each scope** and exposes it as a
|
|
129
|
+
public readonly property named after the folder in camelCase:
|
|
130
|
+
`bolbol.auth`, `bolbol.userFile`, `bolbol.academy`.
|
|
131
|
+
- A scope takes the shared `HTTPClient` in its constructor and nothing else. It
|
|
132
|
+
never calls `fetch` and never reads configuration of its own.
|
|
133
|
+
- There are no scopes for anything that is not a controller folder. Helpers
|
|
134
|
+
that are not endpoints (resolving a static asset path) live on `BolbolServer`.
|
|
135
|
+
|
|
136
|
+
### 2. One method per controller
|
|
137
|
+
|
|
138
|
+
Every controller file in the folder gets exactly one method on its scope - no
|
|
139
|
+
more, no fewer.
|
|
140
|
+
|
|
141
|
+
**Name and grouping.** Methods are grouped into one object per noun, with a
|
|
142
|
+
short verb as the key, so call sites read as `scope.noun.verb(...)`:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
public readonly certificate = {
|
|
146
|
+
list: (teacher_uid: string) => ...,
|
|
147
|
+
create: (teacher_uid: string, request: TeacherCertificateCreateRequestType) => ...,
|
|
148
|
+
update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType) => ...,
|
|
149
|
+
delete: (teacher_uid: string, certificate_id: number) => ...,
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Nest a noun inside another when the URL nests it (`academy.asset.version.publish`).
|
|
154
|
+
|
|
155
|
+
**The marker comment.** The line right above every method is exactly:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// {METHOD} {path exactly as in the controller's info()} - {API name}
|
|
159
|
+
// POST /auth/cli-login/:request_token/approve - AuthCLILoginApprove
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`npm test` reads these markers to find the method for each controller.
|
|
163
|
+
|
|
164
|
+
**The API name** identifies an endpoint everywhere in the SDK. It is every
|
|
165
|
+
folder between `src/controller/` and the file, each in PascalCase, followed by
|
|
166
|
+
the file name without `Controller`. The method lives on the scope of the
|
|
167
|
+
top-level folder:
|
|
168
|
+
|
|
169
|
+
| Controller file | API name |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `auth/LoginController.ts` | `AuthLogin` |
|
|
172
|
+
| `academy/AssetCreateController.ts` | `AcademyAssetCreate` |
|
|
173
|
+
| `user_file/UploadController.ts` | `UserFileUpload` |
|
|
174
|
+
| `teacher/UpdatePhotoController.ts` | `TeacherUpdatePhoto` |
|
|
175
|
+
| `bolbol/teacher_exam/CountPendingController.ts` | `BolbolTeacherExamCountPending` (on `BolbolBolbolScope`) |
|
|
176
|
+
|
|
177
|
+
**The signature mirrors the controller:**
|
|
178
|
+
|
|
179
|
+
- **Path parameters** come first, as positional arguments in the order they
|
|
180
|
+
appear in the path, named exactly like the path segment (`teacher_uid`,
|
|
181
|
+
`certificate_id`) and typed like the controller's parameter schema
|
|
182
|
+
(`string` for a uid or token, `number` for a numeric id).
|
|
183
|
+
- **Then one `request` argument** of type `{API name}RequestType`, when the
|
|
184
|
+
controller takes input beyond the path: its body schema for POST / PUT /
|
|
185
|
+
PATCH / DELETE, its query schema for GET. When every field of a **query** is
|
|
186
|
+
optional the argument defaults to `{}`. A body never defaults, even when all
|
|
187
|
+
its fields are optional: some updates clear every field they are not sent, so
|
|
188
|
+
sending one must be deliberate.
|
|
189
|
+
- **The return type** is `Promise<BolbolResponseType<{API name}ResponseType>>`,
|
|
190
|
+
where the ResponseType is exactly what the controller's `handle()` returns.
|
|
191
|
+
- The HTTP method and path are the controller's own. Interpolate path
|
|
192
|
+
parameters with `encodeURIComponent` for strings.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
// PUT /teacher/:teacher_uid/certificate/:certificate_id - TeacherCertificateUpdate
|
|
196
|
+
update: (teacher_uid: string, certificate_id: number, request: TeacherCertificateUpdateRequestType): Promise<BolbolResponseType<TeacherCertificateUpdateResponseType>> =>
|
|
197
|
+
this.http.put(`/teacher/${encodeURIComponent(teacher_uid)}/certificate/${certificate_id}`, { body: request }),
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Convenience that the backend does not have - reading a browser `File` into
|
|
201
|
+
base64 before an upload, turning a path into a URL - may wrap the call, but the
|
|
202
|
+
arguments that reach the wire must still be exactly the controller's.
|
|
203
|
+
|
|
204
|
+
### 3. Types: one per file, named by what they are
|
|
205
|
+
|
|
206
|
+
Every type the SDK exposes lives in `src/type/`, **one exported type per file,
|
|
207
|
+
file named exactly after the type**. No barrels, no `types.ts`, no type declared
|
|
208
|
+
inside a scope file. The suffix says what kind of type it is, and there are only
|
|
209
|
+
five:
|
|
210
|
+
|
|
211
|
+
| Suffix | What it is | Named after | Example |
|
|
212
|
+
| --- | --- | --- | --- |
|
|
213
|
+
| `RowType` | One row of a database table, as the API sends it (the backend's `Public*` projection when it has one - fields that never leave the backend, like a numeric `id` behind a `uid`, are left out) | The table, in PascalCase | `UserRowType`, `TeacherCertificateRowType`, `ItemVersionRowType` |
|
|
214
|
+
| `RequestType` | The input of one endpoint - its body (or query, for a GET) | The API name | `AuthLoginRequestType`, `AcademyAssetCreateRequestType` |
|
|
215
|
+
| `ResponseType` | The `result` of one endpoint | The API name | `AuthLoginResponseType`, `AcademyAssetListResponseType` |
|
|
216
|
+
| `Enum` | A database or API enum, as a TypeScript `enum`. Every member is PascalCase and its value is exactly its name (`PastDue = "PastDue"`) | The backend enum without its `Database_` prefix, in PascalCase | `RequestStatusEnum` (`Database_Request_Status`), `CLILoginRequestStatusEnum` |
|
|
217
|
+
| `Type` | Anything else shared by several of the above: a JSON column's shape, a generic wrapper | What it describes | `BolbolResponseType<T>`, `ListPageType<T>`, `UserSettingGeneralType` |
|
|
218
|
+
|
|
219
|
+
Rules that make the table work:
|
|
220
|
+
|
|
221
|
+
- **Every endpoint has its own RequestType (if it takes input) and its own
|
|
222
|
+
ResponseType** - even when two endpoints answer the same shape. Then the
|
|
223
|
+
second one is an alias, not a copy:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// AcademyAssetGetResponseType.ts
|
|
227
|
+
import { AssetRowType } from "./AssetRowType";
|
|
228
|
+
|
|
229
|
+
export interface AcademyAssetGetResponseType
|
|
230
|
+
{
|
|
231
|
+
item: AssetRowType;
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// AcademyAssetUpdateResponseType.ts
|
|
237
|
+
import { AcademyAssetGetResponseType } from "./AcademyAssetGetResponseType";
|
|
238
|
+
|
|
239
|
+
export type AcademyAssetUpdateResponseType = AcademyAssetGetResponseType;
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
This keeps "what does endpoint X take and answer" a single lookup, and lets
|
|
243
|
+
one endpoint change shape without dragging the others along.
|
|
244
|
+
- **Paged list requests extend `ListPagingType`**; paged list answers use
|
|
245
|
+
`ListPageType<Row>`, unpaged ones `ListType<Row>`.
|
|
246
|
+
- **Field names are the wire names** - snake_case, exactly as the backend's Zod
|
|
247
|
+
schema or projection spells them. Never camelCase a field.
|
|
248
|
+
- **Optional vs nullable follow the backend**: `.optional()` → `field?: T`,
|
|
249
|
+
`.nullable()` → `field: T | null`, `.nullish()` → `field?: T | null`.
|
|
250
|
+
- Dates travel as ISO strings, so they are `string` here, never `Date`.
|
|
251
|
+
- Each type file starts with a comment naming its source in the backend
|
|
252
|
+
(`// Mirrors PublicTeacher in Bolbol API src/service/teacher/TeacherPublic.ts`).
|
|
253
|
+
- **The Bolbol API imports these types too.** Its `toPublic*()` projections
|
|
254
|
+
return the SDK's RowTypes / Types, and the enums whose values go over the wire
|
|
255
|
+
(`UserPolicyEnum`, `RequestStatusEnum`, `ItemTypeEnum`, ...) exist ONLY here -
|
|
256
|
+
the backend's database columns are declared from them. So for those, this
|
|
257
|
+
package is the source of truth, not a mirror: a change here changes the
|
|
258
|
+
backend's database enum and its compile-time contract. Keep every enum's keys
|
|
259
|
+
equal to its values - the backend builds columns with `Object.keys(...)`.
|
|
260
|
+
|
|
261
|
+
### Translatable columns: `TranslationType`
|
|
262
|
+
|
|
263
|
+
A database column whose text is read in several languages travels with a
|
|
264
|
+
sibling `<column>_translations` field (the backend rule is
|
|
265
|
+
`Bolbol API/rules/Translation.md`). Its shape is one shared type:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// TranslationType.ts - keyed by LanguageCodeType, the 32 hard-coded language
|
|
269
|
+
// codes ("en", "fr", "fa", ...); a language with no translation is an absent key.
|
|
270
|
+
export type TranslationType = { [language_code in LanguageCodeType]?: string };
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- **RowType:** the field sits directly below its source field and is always
|
|
274
|
+
present - the backend stores `{}` for "no translations", never `null`, even
|
|
275
|
+
when the source field is nullable:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
name: string;
|
|
279
|
+
name_translations: TranslationType;
|
|
280
|
+
content: string | null;
|
|
281
|
+
content_translations: TranslationType;
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The map only ever holds languages that have a value - never all languages
|
|
285
|
+
with blanks.
|
|
286
|
+
|
|
287
|
+
- **RequestType:** every Create / Update that takes the source field takes its
|
|
288
|
+
`<column>_translations` too, as a **patch of only the changed languages**, not
|
|
289
|
+
the whole map. Its type is the second shared type:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// TranslationPatchType.ts
|
|
293
|
+
// Mirrors translationSchema() in Bolbol API src/controller/TranslationSchema.ts.
|
|
294
|
+
// Only the languages that changed: a string sets that language, null removes
|
|
295
|
+
// it, a language not listed is left as stored.
|
|
296
|
+
export type TranslationPatchType = { [language_code in LanguageCodeType]?: string | null };
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Optionality follows the Zod schema (`translationSchema(...).optional()` →
|
|
300
|
+
`name_translations?: TranslationPatchType`). Leave the field out when no
|
|
301
|
+
language changed.
|
|
302
|
+
- **Flattened detail columns (`academy.item_version.details_*`)** each get their
|
|
303
|
+
own sibling `_translations` column, same convention as every other
|
|
304
|
+
translation column. Plain translations use `TranslationType` /
|
|
305
|
+
`TranslationPatchType`; choice-keyed translations (e.g.
|
|
306
|
+
`details_multiplechoice_choices_translations`) use
|
|
307
|
+
`ChoiceTranslationType<TranslationType>` on the RowType and
|
|
308
|
+
`ChoiceTranslationType<TranslationPatchType | null>` on the RequestType
|
|
309
|
+
(`null` on a choice removes all its translations). Shape and rules:
|
|
310
|
+
`Bolbol API/rules/Translation.md`, "Translating `academy.item_version` detail
|
|
311
|
+
columns".
|
|
312
|
+
- Use `TranslationType` / `TranslationPatchType` by name. Never inline
|
|
313
|
+
`Record<string, string>` or `{ [k: string]: string }` in a RowType or
|
|
314
|
+
RequestType - one name is what lets every app find (and type-check) every
|
|
315
|
+
translatable field.
|
|
316
|
+
- Reading one: `row.name_translations[code] ?? row.name`. The SDK has no helper
|
|
317
|
+
for this on purpose (rule 1: no non-endpoint code outside `BolbolServer`); each
|
|
318
|
+
app wraps that one expression in its own picker.
|
|
319
|
+
|
|
320
|
+
### 4. Wire format
|
|
321
|
+
|
|
322
|
+
- The API reads the session token from the `b-token` header. `HTTPClient` adds
|
|
323
|
+
it to **every** request whenever `getToken` returns one - public endpoints
|
|
324
|
+
included - and scopes never set it by hand. There is no per-call auth flag on
|
|
325
|
+
purpose: a public endpoint treats a missing or stale token as a guest, while a
|
|
326
|
+
valid one personalises the answer (member-only items, the owner's private
|
|
327
|
+
files, the roster of an academy you own, errors in the user's language).
|
|
328
|
+
Sending the token only to endpoints that require sign-in would silently lose
|
|
329
|
+
all of that.
|
|
330
|
+
- Bodies are JSON. Query values are strings on the wire; `HTTPClient`
|
|
331
|
+
serializes numbers and booleans, repeats a key for an array, and drops
|
|
332
|
+
`null` / `undefined`.
|
|
333
|
+
- Endpoints that stream bytes instead of an envelope use `this.http.blob(...)`.
|
|
334
|
+
- Endpoints that take raw bytes instead of a JSON body (`UserFileContentUpload`)
|
|
335
|
+
have no body schema; their method takes a trailing `content: HTTPRawBody` (a
|
|
336
|
+
Blob, or a ReadableStream to count the bytes as they go) and sends
|
|
337
|
+
it as `{ raw: content }` (`Content-Type: application/octet-stream`, no
|
|
338
|
+
per-request timeout). In Node, pass `await fs.openAsBlob(path)` so a large
|
|
339
|
+
file streams from disk.
|
|
340
|
+
|
|
341
|
+
## Adding or changing an endpoint
|
|
342
|
+
|
|
343
|
+
1. Add or update the RowTypes / Enums the change needs in `src/type/`, and the
|
|
344
|
+
endpoint's RequestType / ResponseType, following rule 3.
|
|
345
|
+
2. Add or update its method on the folder's scope, with the marker comment,
|
|
346
|
+
following rule 2. A new controller folder means a new
|
|
347
|
+
`Bolbol{Folder}Scope.ts` and one more property on `BolbolServer`.
|
|
348
|
+
3. Bump the version and publish.
|
|
349
|
+
4. In the Bolbol API: bump `bolbol-sdk`, change the controller, and make its
|
|
350
|
+
projection return the new type - the compiler shows everything that no
|
|
351
|
+
longer matches.
|
|
352
|
+
5. `npm test` here (against the updated API) and `npm run build`.
|
|
353
|
+
6. Bump `bolbol-sdk` in the Console and the CLI and fix their callers.
|
|
354
|
+
|
|
355
|
+
## Checking the SDK against the API
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
npm test # expects the API at ../Bolbol API
|
|
359
|
+
BOLBOL_API_PATH=<path to Bolbol API> npm test
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`test/api-contract.test.mjs` reads both code bases with the TypeScript compiler
|
|
363
|
+
(nothing runs, no server needed) and, for **every** backend controller, checks:
|
|
364
|
+
|
|
365
|
+
| # | Check |
|
|
366
|
+
| --- | --- |
|
|
367
|
+
| 1 | a scope method carries the controller's marker comment |
|
|
368
|
+
| 2 | it lives on `Bolbol{TopFolder}Scope` |
|
|
369
|
+
| 3 | the marker's path and the URL the method actually builds equal the controller's path |
|
|
370
|
+
| 4 | it calls `this.http.<method>` as the controller's `info()` says (`blob` for a streamed GET) |
|
|
371
|
+
| 5 | it takes `request: {API name}RequestType` exactly when the controller has a body (or, for a GET, a query) schema, sends it as `{ body: request }` / `{ query: request }`, and the RequestType's field names and optionality equal the Zod schema's |
|
|
372
|
+
| 6 | it returns `Promise<BolbolResponseType<{API name}ResponseType>>` and that type exists |
|
|
373
|
+
| 7 | its leading arguments are the path parameters - same names, path order, `number` / `string` as the parameter schema says |
|
|
374
|
+
|
|
375
|
+
plus: no SDK method without a controller, no duplicates, and every type file
|
|
376
|
+
exporting exactly one type named after the file, with one of the five suffixes.
|
|
377
|
+
|
|
378
|
+
What it cannot read is the shape of a controller's *output* (`handle()` builds
|
|
379
|
+
it in code), so a ResponseType's fields are still a review question - the
|
|
380
|
+
marker comment and the one-file-per-type rule make that quick to answer.
|
|
381
|
+
|
|
382
|
+
The parser is TypeScript 5's compiler API (dev dependency `typescript5`),
|
|
383
|
+
because TypeScript 7, which builds the SDK, has no stable JavaScript API.
|
|
384
|
+
|
|
385
|
+
## Scripts
|
|
386
|
+
|
|
387
|
+
| Script | What it does |
|
|
388
|
+
| --- | --- |
|
|
389
|
+
| `npm run build` | Regenerate `src/index.ts`, then compile to `dist/` |
|
|
390
|
+
| `npm run typecheck` | Regenerate `src/index.ts`, then type check without emitting |
|
|
391
|
+
| `npm test` | Check every backend controller against the SDK (see above) |
|
|
392
|
+
| `npm run index` | Regenerate `src/index.ts` only |
|
|
393
|
+
| `npm run clean` | Remove `dist/` |
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "bolbol-sdk",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Typed client for the Bolbol API: one scope per backend controller folder, one type per file.",
|
|
5
|
-
"license": "ISC",
|
|
6
|
-
"author": "",
|
|
7
|
-
"type": "commonjs",
|
|
8
|
-
"main": "dist/index.js",
|
|
9
|
-
"types": "dist/index.d.ts",
|
|
10
|
-
"files": [
|
|
11
|
-
"dist",
|
|
12
|
-
"README.md"
|
|
13
|
-
],
|
|
14
|
-
"engines": {
|
|
15
|
-
"node": ">=20"
|
|
16
|
-
},
|
|
17
|
-
"scripts": {
|
|
18
|
-
"index": "node scripts/generate-index.mjs",
|
|
19
|
-
"build": "npm run index && tsc",
|
|
20
|
-
"typecheck": "npm run index && tsc --noEmit",
|
|
21
|
-
"test": "node --test \"test/**/*.test.mjs\"",
|
|
22
|
-
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
23
|
-
"prepublishOnly": "npm run clean && npm run build"
|
|
24
|
-
},
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"typescript": "^7.0.2",
|
|
27
|
-
"typescript5": "npm:typescript@^7.0.2"
|
|
28
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "bolbol-sdk",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Typed client for the Bolbol API: one scope per backend controller folder, one type per file.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "Ilia Abolhasani, Amir Abolhasani",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"index": "node scripts/generate-index.mjs",
|
|
19
|
+
"build": "npm run index && tsc",
|
|
20
|
+
"typecheck": "npm run index && tsc --noEmit",
|
|
21
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
22
|
+
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
23
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^7.0.2",
|
|
27
|
+
"typescript5": "npm:typescript@^7.0.2"
|
|
28
|
+
}
|
|
29
29
|
}
|