@ultimabase/rest 0.0.0-stage → 0.1.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -2
  3. package/dist/entries.d.ts +38 -0
  4. package/dist/entries.d.ts.map +1 -0
  5. package/dist/entries.js +47 -0
  6. package/dist/fields.d.ts +77 -0
  7. package/dist/fields.d.ts.map +1 -0
  8. package/dist/fields.js +288 -0
  9. package/dist/functions.d.ts +28 -0
  10. package/dist/functions.d.ts.map +1 -0
  11. package/dist/functions.js +156 -0
  12. package/dist/index.d.ts +39 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +62 -0
  15. package/dist/json-schema.d.ts +8 -0
  16. package/dist/json-schema.d.ts.map +1 -0
  17. package/dist/json-schema.js +44 -0
  18. package/dist/limits.d.ts +6 -0
  19. package/dist/limits.d.ts.map +1 -0
  20. package/dist/limits.js +17 -0
  21. package/dist/live.d.ts +15 -0
  22. package/dist/live.d.ts.map +1 -0
  23. package/dist/live.js +168 -0
  24. package/dist/models.d.ts +8 -0
  25. package/dist/models.d.ts.map +1 -0
  26. package/dist/models.js +136 -0
  27. package/dist/openapi-functions.d.ts +11 -0
  28. package/dist/openapi-functions.d.ts.map +1 -0
  29. package/dist/openapi-functions.js +85 -0
  30. package/dist/openapi-inputs.d.ts +30 -0
  31. package/dist/openapi-inputs.d.ts.map +1 -0
  32. package/dist/openapi-inputs.js +316 -0
  33. package/dist/openapi-models.d.ts +14 -0
  34. package/dist/openapi-models.d.ts.map +1 -0
  35. package/dist/openapi-models.js +280 -0
  36. package/dist/openapi.d.ts +47 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +150 -0
  39. package/dist/params.d.ts +11 -0
  40. package/dist/params.d.ts.map +1 -0
  41. package/dist/params.js +27 -0
  42. package/dist/sse.d.ts +17 -0
  43. package/dist/sse.d.ts.map +1 -0
  44. package/dist/sse.js +63 -0
  45. package/dist/streams.d.ts +73 -0
  46. package/dist/streams.d.ts.map +1 -0
  47. package/dist/streams.js +218 -0
  48. package/dist/values.d.ts +7 -0
  49. package/dist/values.d.ts.map +1 -0
  50. package/dist/values.js +123 -0
  51. package/package.json +47 -3
  52. package/src/entries.ts +82 -0
  53. package/src/fields.ts +391 -0
  54. package/src/functions.ts +205 -0
  55. package/src/index.ts +105 -0
  56. package/src/json-schema.ts +51 -0
  57. package/src/limits.ts +28 -0
  58. package/src/live.ts +223 -0
  59. package/src/models.ts +180 -0
  60. package/src/openapi-functions.ts +127 -0
  61. package/src/openapi-inputs.ts +399 -0
  62. package/src/openapi-models.ts +367 -0
  63. package/src/openapi.ts +204 -0
  64. package/src/params.ts +39 -0
  65. package/src/sse.ts +79 -0
  66. package/src/streams.ts +301 -0
  67. package/src/values.ts +163 -0
@@ -0,0 +1,367 @@
1
+ // A model's part of the document (D-144): its row, as GraphQL's type, its
2
+ // pages, its `select` and `include`, and its six routes.
3
+ import {
4
+ isNode,
5
+ jsonSchemaOf,
6
+ readable,
7
+ readableVirtuals,
8
+ } from '@ultimabase/http/internal'
9
+ import type { ExposedVirtual } from 'ultimabase'
10
+ import type { Entry } from './entries.ts'
11
+ import { entryOf, system } from './entries.ts'
12
+ import type { Doc } from './openapi-inputs.ts'
13
+ import {
14
+ dataOf,
15
+ orderByOf,
16
+ uniqueOf,
17
+ valueOf,
18
+ whereOf,
19
+ } from './openapi-inputs.ts'
20
+ import { outputOf } from './openapi-functions.ts'
21
+ import type { Schema } from './openapi.ts'
22
+ import {
23
+ arrayOf,
24
+ describe,
25
+ described,
26
+ failed,
27
+ hoisted,
28
+ json,
29
+ objectOf,
30
+ orNull,
31
+ } from './openapi.ts'
32
+
33
+ const upper = (name: string): string =>
34
+ name.charAt(0).toUpperCase() + name.slice(1)
35
+
36
+ const text: Schema = { type: 'string' }
37
+ const flag: Schema = { type: 'boolean' }
38
+
39
+ /** `PageInfo`, `PostEdge` and a connection of its edges. */
40
+ function connectionOf(doc: Doc, name: string, target: string): Schema {
41
+ const { components } = doc
42
+ return components.ref(name, () => {
43
+ const pageInfo = components.ref('PageInfo', () =>
44
+ objectOf(
45
+ {
46
+ hasNextPage: flag,
47
+ hasPreviousPage: flag,
48
+ startCursor: orNull(text),
49
+ endCursor: orNull(text),
50
+ },
51
+ ['hasNextPage', 'hasPreviousPage', 'startCursor', 'endCursor'],
52
+ ),
53
+ )
54
+ const edge = components.ref(`${target}Edge`, () =>
55
+ objectOf({ cursor: text, node: rowOf(doc, target) }, ['cursor', 'node']),
56
+ )
57
+ return objectOf(
58
+ {
59
+ edges: arrayOf(edge),
60
+ pageInfo,
61
+ totalCount: { type: 'integer', minimum: 0 },
62
+ },
63
+ ['edges', 'pageInfo'],
64
+ )
65
+ })
66
+ }
67
+
68
+ /**
69
+ * `Post`: its default fields, which a row always holds without `select`,
70
+ * and its relations, which `include` adds. A field with a read rule may be
71
+ * `null`, a `one` relation always may (D-44).
72
+ */
73
+ export function rowOf(doc: Doc, model: string): Schema {
74
+ return doc.components.ref(model, () => {
75
+ const entry = entryOf(doc.api, model)
76
+ const date: Schema = { type: 'string', format: 'date-time' }
77
+ const fields: { [name: string]: Schema } = {
78
+ id: text,
79
+ createdAt: date,
80
+ updatedAt: date,
81
+ }
82
+ const required = [...system]
83
+ for (const field of readable(entry)) {
84
+ const ruled = entry.exposed.readRules[field.name] === 'rule'
85
+ if (field.kind === 'scalar') {
86
+ const value = valueOf(doc, entry, field, 'output')
87
+ const empty = ruled || (field.optional && !field.list)
88
+ fields[field.name] = empty ? orNull(value) : value
89
+ required.push(field.name)
90
+ } else if (field.kind === 'one') {
91
+ fields[field.name] = orNull(rowOf(doc, field.target))
92
+ } else {
93
+ const many =
94
+ doc.api.pagination === 'connection'
95
+ ? connectionOf(
96
+ doc,
97
+ `${model}${upper(field.name)}Connection`,
98
+ field.target,
99
+ )
100
+ : arrayOf(rowOf(doc, field.target))
101
+ fields[field.name] = ruled ? orNull(many) : many
102
+ }
103
+ }
104
+ // A virtual, computed: in the default row without an input (D-151).
105
+ for (const virtual of readableVirtuals(entry)) {
106
+ const { name } = virtual
107
+ const owner = `${model}.${name}`
108
+ const value = {
109
+ ...outputOf(doc, virtual.output, owner, `${model}${upper(name)}Output`),
110
+ readOnly: true,
111
+ }
112
+ const ruled = entry.exposed.readRules[name] === 'rule'
113
+ fields[name] = describe(
114
+ ruled ? orNull(value) : value,
115
+ virtual.description,
116
+ )
117
+ if (virtual.default) required.push(name)
118
+ }
119
+ const { description, descriptions } = entry.exposed
120
+ return describe(
121
+ objectOf(described(fields, descriptions), required),
122
+ description,
123
+ )
124
+ })
125
+ }
126
+
127
+ /** A list's page arguments, within the model's `maxTake`. */
128
+ function pageOf(doc: Doc, entry: Entry): { [name: string]: Schema } {
129
+ const rows: Schema = { type: 'integer', minimum: 0, maximum: entry.maxTake }
130
+ return doc.api.pagination === 'list'
131
+ ? { take: rows, skip: { type: 'integer', minimum: 0 } }
132
+ : { first: rows, after: text, last: rows, before: text }
133
+ }
134
+
135
+ /** `PostSelect`, `PostInclude`, a relation's arguments (D-129). */
136
+ function fieldsOf(doc: Doc, model: string, select: boolean): Schema {
137
+ const name = `${model}${select ? 'Select' : 'Include'}`
138
+ return doc.components.ref(name, () => {
139
+ const entry = entryOf(doc.api, model)
140
+ const fields: { [name: string]: Schema } = {}
141
+ if (select) for (const key of system) fields[key] = flag
142
+ for (const field of readable(entry)) {
143
+ if (field.kind === 'scalar') {
144
+ if (select) fields[field.name] = flag
145
+ continue
146
+ }
147
+ const args =
148
+ field.kind === 'one'
149
+ ? argsOf(doc, field.target)
150
+ : listArgsOf(doc, field.target)
151
+ fields[field.name] = { anyOf: [flag, args] }
152
+ }
153
+ for (const virtual of readableVirtuals(entry)) {
154
+ fields[virtual.name] = virtualArgsOf(doc, model, virtual)
155
+ }
156
+ return objectOf(fields)
157
+ })
158
+ }
159
+
160
+ const either: Schema = { not: { required: ['select', 'include'] } }
161
+
162
+ /**
163
+ * A virtual in `select` and `include` (D-151): `true` unless its input
164
+ * needs a property, its input, and a model's `select` or `include`.
165
+ */
166
+ function virtualArgsOf(
167
+ doc: Doc,
168
+ model: string,
169
+ virtual: ExposedVirtual,
170
+ ): Schema {
171
+ const { name, input, output } = virtual
172
+ const owner = `${model}.${name}`
173
+ const base = `${model}${upper(name)}`
174
+ const node =
175
+ input === undefined
176
+ ? undefined
177
+ : hoisted(
178
+ doc.components,
179
+ jsonSchemaOf(input, 'input', `${owner}: input`),
180
+ 'input',
181
+ owner,
182
+ `${base}Input`,
183
+ )
184
+ const listed: unknown = node?.['required']
185
+ const required = Array.isArray(listed)
186
+ ? listed.filter((key): key is string => typeof key === 'string')
187
+ : []
188
+ const needed = required.length > 0
189
+ const or = (schema: Schema) => (needed ? schema : { anyOf: [flag, schema] })
190
+ if (output.kind !== 'model') return node === undefined ? flag : or(node)
191
+ const properties = node?.['properties']
192
+ return or(
193
+ doc.components.ref(`${base}Args`, () => ({
194
+ ...objectOf(
195
+ {
196
+ ...(isNode(properties) ? properties : {}),
197
+ select: fieldsOf(doc, output.name, true),
198
+ include: fieldsOf(doc, output.name, false),
199
+ },
200
+ required,
201
+ ),
202
+ ...either,
203
+ })),
204
+ )
205
+ }
206
+
207
+ /** `PostArgs`: a `one` relation's `select` or `include`. */
208
+ function argsOf(doc: Doc, model: string): Schema {
209
+ return doc.components.ref(`${model}Args`, () => ({
210
+ ...objectOf({
211
+ select: fieldsOf(doc, model, true),
212
+ include: fieldsOf(doc, model, false),
213
+ }),
214
+ ...either,
215
+ }))
216
+ }
217
+
218
+ /** `PostListArgs`: a `many` relation's, its page and filter too. */
219
+ function listArgsOf(doc: Doc, model: string): Schema {
220
+ return doc.components.ref(`${model}ListArgs`, () => {
221
+ const entry = entryOf(doc.api, model)
222
+ const counts = doc.api.pagination === 'connection'
223
+ return {
224
+ ...objectOf({
225
+ select: fieldsOf(doc, model, true),
226
+ include: fieldsOf(doc, model, false),
227
+ where: whereOf(doc, model),
228
+ orderBy: ordering(doc, model),
229
+ ...pageOf(doc, entry),
230
+ ...(counts && { totalCount: flag }),
231
+ }),
232
+ ...either,
233
+ }
234
+ })
235
+ }
236
+
237
+ const ordering = (doc: Doc, model: string): Schema => {
238
+ const order = orderByOf(doc, model)
239
+ return { anyOf: [order, arrayOf(order)] }
240
+ }
241
+
242
+ /** A query parameter: JSON, or a string or a number as is. */
243
+ const param = (name: string, schema: Schema, description?: string): Schema => {
244
+ const plain = schema['type'] === 'string' || schema['type'] === 'integer'
245
+ return {
246
+ name,
247
+ in: 'query',
248
+ ...(description !== undefined && { description }),
249
+ ...(plain || schema === flag ? { schema } : { content: json(schema) }),
250
+ }
251
+ }
252
+
253
+ function fieldParams(doc: Doc, model: string): readonly Schema[] {
254
+ return [
255
+ param(
256
+ 'select',
257
+ fieldsOf(doc, model, true),
258
+ 'The fields to return, and only those; not with include.',
259
+ ),
260
+ param(
261
+ 'include',
262
+ fieldsOf(doc, model, false),
263
+ 'The relations to add to the default fields.',
264
+ ),
265
+ ]
266
+ }
267
+
268
+ const answer = (description: string, schema: Schema): Schema => ({
269
+ description,
270
+ content: json(schema),
271
+ })
272
+
273
+ const body = (schema: Schema): Schema => ({
274
+ required: true,
275
+ content: json(schema),
276
+ })
277
+
278
+ /** `/posts` and `/posts/{id}`, as REST serves them (D-129, D-142). */
279
+ export function modelPaths(doc: Doc, entry: Entry): { [path: string]: Schema } {
280
+ const { components } = doc
281
+ const model = entry.model.name
282
+ const own = (id: string) => components.operation(id, model, 'plural')
283
+ const tags = [model]
284
+ const row = rowOf(doc, model)
285
+ const counts = doc.api.pagination === 'connection'
286
+ const list = counts
287
+ ? connectionOf(doc, `${model}Connection`, model)
288
+ : arrayOf(row)
289
+ const pages = Object.entries(pageOf(doc, entry)).map(([name, schema]) =>
290
+ param(name, schema),
291
+ )
292
+ const one = model.charAt(0).toLowerCase() + model.slice(1)
293
+ const id = {
294
+ name: 'id',
295
+ in: 'path',
296
+ required: true,
297
+ schema: text,
298
+ }
299
+ return {
300
+ [`/${entry.plural}`]: {
301
+ get: {
302
+ operationId: own(entry.plural),
303
+ tags,
304
+ parameters: [
305
+ param('where', whereOf(doc, model)),
306
+ param('orderBy', ordering(doc, model)),
307
+ ...fieldParams(doc, model),
308
+ ...pages,
309
+ ...(counts ? [param('totalCount', flag)] : []),
310
+ ],
311
+ responses: { 200: answer(`The ${model} rows.`, list), ...failed },
312
+ },
313
+ post: {
314
+ operationId: own(`create${model}`),
315
+ tags,
316
+ parameters: fieldParams(doc, model),
317
+ requestBody: body(dataOf(doc, model, true)),
318
+ responses: { 201: answer(`The ${model} created.`, row), ...failed },
319
+ },
320
+ put: {
321
+ operationId: own(`upsert${model}`),
322
+ tags,
323
+ description: `Updates the ${model} where matches, or creates it.`,
324
+ parameters: fieldParams(doc, model),
325
+ requestBody: body(
326
+ objectOf(
327
+ {
328
+ where: uniqueOf(doc, model),
329
+ create: dataOf(doc, model, true),
330
+ update: dataOf(doc, model, false),
331
+ },
332
+ ['where', 'create', 'update'],
333
+ ),
334
+ ),
335
+ responses: {
336
+ 200: answer(`The ${model} updated.`, row),
337
+ 201: answer(`The ${model} created.`, row),
338
+ ...failed,
339
+ },
340
+ },
341
+ },
342
+ [`/${entry.plural}/{id}`]: {
343
+ parameters: [id],
344
+ get: {
345
+ operationId: own(one),
346
+ tags,
347
+ parameters: fieldParams(doc, model),
348
+ responses: { 200: answer(`The ${model}.`, row), ...failed },
349
+ },
350
+ patch: {
351
+ operationId: own(`update${model}`),
352
+ tags,
353
+ parameters: fieldParams(doc, model),
354
+ requestBody: body(dataOf(doc, model, false)),
355
+ responses: { 200: answer(`The ${model} updated.`, row), ...failed },
356
+ },
357
+ delete: {
358
+ operationId: own(`delete${model}`),
359
+ tags,
360
+ responses: {
361
+ 200: answer(`The ${model} as it was, without relations.`, row),
362
+ ...failed,
363
+ },
364
+ },
365
+ },
366
+ }
367
+ }
package/src/openapi.ts ADDED
@@ -0,0 +1,204 @@
1
+ // The OpenAPI 3.1 document (D-144): REST's routes and their JSON, typed as
2
+ // GraphQL types them and named as it names them, seen as a caller other
3
+ // than Master. Built once, at startup.
4
+ import type { Node } from '@ultimabase/http/internal'
5
+ import { isNode } from '@ultimabase/http/internal'
6
+ import { Refused } from 'ultimabase'
7
+ import type { Api } from './entries.ts'
8
+ import type { Routed } from './functions.ts'
9
+ import { functionPaths } from './openapi-functions.ts'
10
+ import { modelPaths } from './openapi-models.ts'
11
+
12
+ export type Schema = Node
13
+
14
+ export const refTo = (name: string): Schema => ({
15
+ $ref: `#/components/schemas/${name}`,
16
+ })
17
+
18
+ /** `schema` or `null`; any value already takes `null`. */
19
+ export const orNull = (schema: Schema): Schema =>
20
+ Object.keys(schema).length === 0
21
+ ? schema
22
+ : { anyOf: [schema, { type: 'null' }] }
23
+
24
+ export const arrayOf = (items: Schema): Schema => ({ type: 'array', items })
25
+
26
+ /** `schema` with its `description`, when it has one (D-150). */
27
+ export const describe = (
28
+ schema: Schema,
29
+ description: string | undefined,
30
+ ): Schema => (description === undefined ? schema : { ...schema, description })
31
+
32
+ /** Each property with its field's `description` (D-150). */
33
+ export const described = (
34
+ properties: { readonly [key: string]: Schema },
35
+ descriptions: { readonly [field: string]: string },
36
+ ): { readonly [key: string]: Schema } =>
37
+ Object.fromEntries(
38
+ Object.entries(properties).map(([key, schema]) => [
39
+ key,
40
+ describe(schema, descriptions[key]),
41
+ ]),
42
+ )
43
+
44
+ /** An object of these properties, and no other. */
45
+ export const objectOf = (
46
+ properties: { readonly [key: string]: Schema },
47
+ required: readonly string[] = [],
48
+ ): Schema => ({
49
+ type: 'object',
50
+ properties,
51
+ ...(required.length > 0 && { required }),
52
+ additionalProperties: false,
53
+ })
54
+
55
+ /** A JSON value under `name`, as a parameter or a body holds it. */
56
+ export const json = (schema: Schema): Schema => ({
57
+ 'application/json': { schema },
58
+ })
59
+
60
+ /** The answer of each failed request (D-106). */
61
+ export const failed: Schema = {
62
+ default: {
63
+ description: 'The request failed.',
64
+ content: json(refTo('Error')),
65
+ },
66
+ }
67
+
68
+ const errorSchema: Schema = {
69
+ type: 'object',
70
+ properties: {
71
+ code: { type: 'string' },
72
+ message: { type: 'string' },
73
+ details: {},
74
+ correlationId: { type: 'string' },
75
+ },
76
+ required: ['code', 'message'],
77
+ }
78
+
79
+ type Known = { readonly owner: string; readonly key: string | undefined }
80
+
81
+ const rest = 'REST'
82
+
83
+ const clash = (owner: string, name: string): Refused =>
84
+ new Refused(`${owner}: REST names a schema ${name}; give yours another name.`)
85
+
86
+ /** The document's named schemas, each name of one shape. */
87
+ export class Components {
88
+ readonly schemas: { [name: string]: Schema } = {}
89
+ readonly #known = new Map<string, Known>()
90
+ readonly #operations = new Map<string, string>()
91
+
92
+ /** The schema REST names `name`, built on its first use. */
93
+ ref(name: string, build: () => Schema): Schema {
94
+ const known = this.#known.get(name)
95
+ if (known !== undefined && known.owner !== rest)
96
+ throw clash(known.owner, name)
97
+ if (known === undefined) {
98
+ this.#known.set(name, { owner: rest, key: undefined })
99
+ // Set first: a schema may refer to itself.
100
+ this.schemas[name] = {}
101
+ this.schemas[name] = build()
102
+ }
103
+ return refTo(name)
104
+ }
105
+
106
+ /** A schema a JSON Schema names, its id `name`. */
107
+ shared(name: string, schema: Schema, owner: string): Schema {
108
+ if (!/^[\w.-]+$/.test(name)) {
109
+ throw new Refused(
110
+ `${owner}: ${name} is not a schema name, which takes letters, digits, '.', '-' and '_'.`,
111
+ )
112
+ }
113
+ // One shape whatever it says of itself: its first description stays.
114
+ const { description: _, ...shape } = schema
115
+ const key = JSON.stringify(shape)
116
+ const known = this.#known.get(name)
117
+ if (known?.owner === rest) throw clash(owner, name)
118
+ if (known === undefined) {
119
+ this.#known.set(name, { owner, key })
120
+ this.schemas[name] = schema
121
+ } else if (known.key !== key) {
122
+ throw new Refused(
123
+ `${known.owner} and ${owner} give the schema ${name} two shapes; name each once.`,
124
+ )
125
+ }
126
+ return refTo(name)
127
+ }
128
+
129
+ /** An operation's id, which no other operation has. */
130
+ operation(id: string, owner: string, setting: 'plural' | 'name'): string {
131
+ const other = this.#operations.get(id)
132
+ if (other !== undefined) {
133
+ throw new Refused(
134
+ `${owner}: the operation ${id} is ${other}'s; set .with({ rest: { ${setting} } }).`,
135
+ )
136
+ }
137
+ this.#operations.set(id, owner)
138
+ return id
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A JSON Schema with its `$defs` among the components, named by their ids,
144
+ * `Input`-suffixed on the input side as in GraphQL; `#` is `root`'s own.
145
+ */
146
+ export function hoisted(
147
+ components: Components,
148
+ node: Node,
149
+ side: 'input' | 'output',
150
+ owner: string,
151
+ root: string,
152
+ ): Schema {
153
+ const suffix = side === 'input' ? 'Input' : ''
154
+ let recursive = false
155
+ const rewrite = (value: unknown): unknown => {
156
+ if (Array.isArray(value)) return value.map(rewrite)
157
+ if (!isNode(value)) return value
158
+ const ref = value['$ref']
159
+ if (ref === '#') {
160
+ recursive = true
161
+ return { ...value, $ref: refTo(root)['$ref'] }
162
+ }
163
+ if (typeof ref === 'string' && ref.startsWith('#/$defs/')) {
164
+ return { ...value, $ref: refTo(`${ref.slice(8)}${suffix}`)['$ref'] }
165
+ }
166
+ return Object.fromEntries(
167
+ Object.entries(value).map(([key, inner]) => [key, rewrite(inner)]),
168
+ )
169
+ }
170
+ const { $defs: defs, $schema: _, ...own } = node
171
+ if (isNode(defs)) {
172
+ for (const [id, def] of Object.entries(defs)) {
173
+ const schema = rewrite(def)
174
+ if (isNode(schema)) components.shared(`${id}${suffix}`, schema, owner)
175
+ }
176
+ }
177
+ const schema = rewrite(own)
178
+ const result = isNode(schema) ? schema : {}
179
+ return recursive ? components.shared(root, result, owner) : result
180
+ }
181
+
182
+ /** The document `GET {path}/openapi.json` answers. */
183
+ export function openapiOf(
184
+ api: Api,
185
+ functions: readonly Routed[],
186
+ base: string,
187
+ version: string,
188
+ ): Schema {
189
+ const components = new Components()
190
+ components.ref('Error', () => errorSchema)
191
+ const doc = { components, api }
192
+ const paths: { [path: string]: Schema } = {}
193
+ for (const entry of api.entries.values()) {
194
+ if (entry.visible) Object.assign(paths, modelPaths(doc, entry))
195
+ }
196
+ Object.assign(paths, functionPaths(doc, functions))
197
+ return {
198
+ openapi: '3.1.0',
199
+ info: { title: 'UltimaBase REST API', version },
200
+ servers: [{ url: base === '' ? '/' : base }],
201
+ paths,
202
+ components: { schemas: components.schemas },
203
+ }
204
+ }
package/src/params.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Query parameters (D-129): each the JSON of the GraphQL argument of its
2
+ // name, a string argument as is.
3
+ import { badInput } from '@ultimabase/http/internal'
4
+ import type { Args } from './entries.ts'
5
+
6
+ /** What a route reads from its query string. */
7
+ export type Accepted = {
8
+ /** Parameters written as JSON. */
9
+ readonly json: readonly string[]
10
+ /** Parameters written as is. */
11
+ readonly text: readonly string[]
12
+ }
13
+
14
+ function parsed(name: string, value: string): unknown {
15
+ try {
16
+ return JSON.parse(value)
17
+ } catch (error) {
18
+ throw badInput(`The query parameter ${name} is not JSON.`, error)
19
+ }
20
+ }
21
+
22
+ /** The parameters of a request, each once and each one the route takes. */
23
+ export function paramsOf(url: string, accepted: Accepted): Args {
24
+ const params: { [name: string]: unknown } = {}
25
+ const taken = [...accepted.json, ...accepted.text]
26
+ for (const [name, value] of new URL(url).searchParams) {
27
+ if (Object.hasOwn(params, name)) {
28
+ throw badInput(`The query parameter ${name} is given twice.`)
29
+ }
30
+ if (!taken.includes(name)) {
31
+ const takes = taken.length === 0 ? 'none' : taken.join(', ')
32
+ throw badInput(
33
+ `Unknown query parameter '${name}'; this route takes ${takes}.`,
34
+ )
35
+ }
36
+ params[name] = accepted.text.includes(name) ? value : parsed(name, value)
37
+ }
38
+ return params
39
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,79 @@
1
+ // A Server-Sent Events body (D-154): each event written whole, gzipped and
2
+ // flushed after each one when the client accepts gzip, and a comment every
3
+ // 12 s so neither a proxy nor the client takes the stream for dead.
4
+ import type { Transform } from 'node:stream'
5
+ import { PassThrough, Readable } from 'node:stream'
6
+ import { constants, createGzip } from 'node:zlib'
7
+
8
+ /** A client's buffer past which its changes are dropped. */
9
+ const maxBuffer = 1024 * 1024
10
+ /** Between two comments on a quiet stream. */
11
+ const keepAlive = 12_000
12
+
13
+ /** An open stream of events, and the response that sends it. */
14
+ export type Events = {
15
+ readonly response: Response
16
+ /** Writes one event; `id` is the revision it carries. */
17
+ readonly send: (event: string, data: unknown, id?: number) => void
18
+ /** Whether the client's buffer holds more than it may. */
19
+ readonly full: () => boolean
20
+ /** Runs `then` once the client took what it was sent. */
21
+ readonly drained: (then: () => void) => void
22
+ /** Ends the stream. */
23
+ readonly end: () => void
24
+ }
25
+
26
+ /** Whether `header` (an `Accept-Encoding`) takes gzip. */
27
+ export function acceptsGzip(header: string | null): boolean {
28
+ for (const part of (header ?? '').split(',')) {
29
+ const [name = '', ...params] = part.split(';').map((each) => each.trim())
30
+ if (name.toLowerCase() !== 'gzip') continue
31
+ const q = params.find((param) => param.startsWith('q='))
32
+ return q === undefined || Number(q.slice(2)) > 0
33
+ }
34
+ return false
35
+ }
36
+
37
+ /** A stream of events to `request`'s client; `closed` runs once it ends. */
38
+ export function eventsTo(request: Request, closed: () => void): Events {
39
+ const gzip = acceptsGzip(request.headers.get('accept-encoding'))
40
+ const zipped = gzip ? createGzip() : undefined
41
+ const stream: Transform = zipped ?? new PassThrough()
42
+ const write = (text: string) => {
43
+ if (stream.writableEnded || stream.destroyed) return
44
+ stream.write(text)
45
+ zipped?.flush(constants.Z_SYNC_FLUSH)
46
+ }
47
+ const beating = setInterval(() => write(':\n\n'), keepAlive)
48
+ beating.unref()
49
+ stream.once('close', () => {
50
+ clearInterval(beating)
51
+ closed()
52
+ })
53
+ const end = () => {
54
+ clearInterval(beating)
55
+ stream.end()
56
+ }
57
+ request.signal.addEventListener('abort', () => stream.destroy())
58
+ const headers: Record<string, string> = {
59
+ 'content-type': 'text/event-stream; charset=utf-8',
60
+ 'cache-control': 'no-store',
61
+ // nginx buffers a response unless told not to.
62
+ 'x-accel-buffering': 'no',
63
+ ...(gzip ? { 'content-encoding': 'gzip', vary: 'Accept-Encoding' } : {}),
64
+ }
65
+ const body = Readable.toWeb(stream)
66
+ return {
67
+ response: new Response(body, { headers }),
68
+ send: (event, data, id) => {
69
+ const line = id === undefined ? '' : `id: ${id}\n`
70
+ write(`${line}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
71
+ },
72
+ full: () => stream.writableLength + stream.readableLength > maxBuffer,
73
+ drained: (then) => {
74
+ if (stream.writableNeedDrain) stream.once('drain', then)
75
+ else then()
76
+ },
77
+ end,
78
+ }
79
+ }