@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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -2
- package/dist/entries.d.ts +38 -0
- package/dist/entries.d.ts.map +1 -0
- package/dist/entries.js +47 -0
- package/dist/fields.d.ts +77 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +288 -0
- package/dist/functions.d.ts +28 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +156 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +44 -0
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/live.d.ts +15 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +168 -0
- package/dist/models.d.ts +8 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +136 -0
- package/dist/openapi-functions.d.ts +11 -0
- package/dist/openapi-functions.d.ts.map +1 -0
- package/dist/openapi-functions.js +85 -0
- package/dist/openapi-inputs.d.ts +30 -0
- package/dist/openapi-inputs.d.ts.map +1 -0
- package/dist/openapi-inputs.js +316 -0
- package/dist/openapi-models.d.ts +14 -0
- package/dist/openapi-models.d.ts.map +1 -0
- package/dist/openapi-models.js +280 -0
- package/dist/openapi.d.ts +47 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +150 -0
- package/dist/params.d.ts +11 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +27 -0
- package/dist/sse.d.ts +17 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +63 -0
- package/dist/streams.d.ts +73 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +218 -0
- package/dist/values.d.ts +7 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +123 -0
- package/package.json +47 -3
- package/src/entries.ts +82 -0
- package/src/fields.ts +391 -0
- package/src/functions.ts +205 -0
- package/src/index.ts +105 -0
- package/src/json-schema.ts +51 -0
- package/src/limits.ts +28 -0
- package/src/live.ts +223 -0
- package/src/models.ts +180 -0
- package/src/openapi-functions.ts +127 -0
- package/src/openapi-inputs.ts +399 -0
- package/src/openapi-models.ts +367 -0
- package/src/openapi.ts +204 -0
- package/src/params.ts +39 -0
- package/src/sse.ts +79 -0
- package/src/streams.ts +301 -0
- package/src/values.ts +163 -0
package/src/fields.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// `select` and `include` (D-129): what a request asks of a model's rows, as
|
|
2
|
+
// one kernel read, and the rows shaped as GraphQL returns them. Without
|
|
3
|
+
// either, a row holds its readable scalar fields and no relation.
|
|
4
|
+
import type { Relay } from '@ultimabase/http/internal'
|
|
5
|
+
import {
|
|
6
|
+
connection,
|
|
7
|
+
pageArgs,
|
|
8
|
+
pageOf,
|
|
9
|
+
readable,
|
|
10
|
+
readableVirtuals,
|
|
11
|
+
rowOf,
|
|
12
|
+
rowsOf,
|
|
13
|
+
} from '@ultimabase/http/internal'
|
|
14
|
+
import type { ExposedVirtual, Row } from 'ultimabase'
|
|
15
|
+
import { InvalidQuery } from 'ultimabase'
|
|
16
|
+
import type { Api, Args, Entry } from './entries.ts'
|
|
17
|
+
import { entryOf, system } from './entries.ts'
|
|
18
|
+
import { isArgs, whereOf } from './values.ts'
|
|
19
|
+
|
|
20
|
+
/** A field of the response, in the order the request wrote it. */
|
|
21
|
+
type Out =
|
|
22
|
+
| {
|
|
23
|
+
readonly kind: 'scalar'
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly toJSON: ((value: unknown) => unknown) | undefined
|
|
26
|
+
}
|
|
27
|
+
| { readonly kind: 'one'; readonly name: string; readonly shape: Shape }
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'many'
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly shape: Shape
|
|
32
|
+
readonly relay: Relay | undefined
|
|
33
|
+
/** The rows it asks for, which the row budget counts. */
|
|
34
|
+
readonly size: number
|
|
35
|
+
/** Whether it answers its `totalCount` (D-134). */
|
|
36
|
+
readonly counted: boolean
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
/** A virtual's rows of a model, re-read by the kernel (D-151). */
|
|
40
|
+
readonly kind: 'returned'
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly owner: string
|
|
43
|
+
readonly shape: Shape
|
|
44
|
+
readonly list: boolean
|
|
45
|
+
/** The rows it counts: `take` for a list, as a function's (D-109). */
|
|
46
|
+
readonly size: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What a row of the response holds. `sorted` is the relations its list's
|
|
51
|
+
* `orderBy` goes through, each a level of `maxDepth` (D-134).
|
|
52
|
+
*/
|
|
53
|
+
export type Shape = {
|
|
54
|
+
readonly fields: readonly Out[]
|
|
55
|
+
readonly sorted?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A row's fields and its relations' reads, as the kernel takes them. */
|
|
59
|
+
export type Read = {
|
|
60
|
+
readonly read: Args
|
|
61
|
+
readonly shape: Shape
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The relations an `orderBy` goes through, the deepest path's. */
|
|
65
|
+
function sortDepth(orderBy: unknown): number {
|
|
66
|
+
if (Array.isArray(orderBy)) return Math.max(0, ...orderBy.map(sortDepth))
|
|
67
|
+
if (typeof orderBy !== 'object' || orderBy === null) return 0
|
|
68
|
+
return Math.max(
|
|
69
|
+
0,
|
|
70
|
+
...Object.values(orderBy).map((value: unknown) =>
|
|
71
|
+
typeof value === 'object' && value !== null ? 1 + sortDepth(value) : 0,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const unknownKey = (path: string, model: string): InvalidQuery =>
|
|
77
|
+
new InvalidQuery(path, `Unknown key '${path}' on ${model}.`)
|
|
78
|
+
|
|
79
|
+
const scalar = (entry: Entry, name: string): Out => ({
|
|
80
|
+
kind: 'scalar',
|
|
81
|
+
name,
|
|
82
|
+
// A plugin field reads as its toJSON gives (D-122).
|
|
83
|
+
toJSON: entry.exposed.fieldTypes[name]?.toJSON,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `id`, the dates, each scalar field a caller other than Master reads, and
|
|
88
|
+
* each virtual of a scalar without an input (D-151).
|
|
89
|
+
*/
|
|
90
|
+
export const defaultShape = (entry: Entry): Shape => ({
|
|
91
|
+
fields: [
|
|
92
|
+
...system.map((name) => scalar(entry, name)),
|
|
93
|
+
...readable(entry)
|
|
94
|
+
.filter((field) => field.kind === 'scalar')
|
|
95
|
+
.map((field) => scalar(entry, field.name)),
|
|
96
|
+
...defaultVirtuals(entry).map((name) => scalar(entry, name)),
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** The virtuals a row holds without `select` (D-151). */
|
|
101
|
+
export const defaultVirtuals = (entry: Entry): readonly string[] =>
|
|
102
|
+
readableVirtuals(entry)
|
|
103
|
+
.filter((virtual) => virtual.default)
|
|
104
|
+
.map((virtual) => virtual.name)
|
|
105
|
+
|
|
106
|
+
// `take` for none or a negative number; above `maxTake`, the list refuses.
|
|
107
|
+
const sizeOf = (entry: Entry, asked: unknown): number =>
|
|
108
|
+
typeof asked === 'number' && asked >= 0
|
|
109
|
+
? Math.min(asked, entry.maxTake)
|
|
110
|
+
: entry.take
|
|
111
|
+
|
|
112
|
+
/** A list's `where`, its dates read; `null` is absent, as in GraphQL. */
|
|
113
|
+
export const filterOf = (
|
|
114
|
+
api: Api,
|
|
115
|
+
entry: Entry,
|
|
116
|
+
where: unknown,
|
|
117
|
+
path: string,
|
|
118
|
+
): Args =>
|
|
119
|
+
where === undefined || where === null
|
|
120
|
+
? {}
|
|
121
|
+
: {
|
|
122
|
+
where: isArgs(where)
|
|
123
|
+
? whereOf(api, entry.model.name, where, path)
|
|
124
|
+
: where,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Reads `select` or `include` as the request wrote them. */
|
|
128
|
+
class Reader {
|
|
129
|
+
readonly #api: Api
|
|
130
|
+
readonly #master: boolean
|
|
131
|
+
|
|
132
|
+
constructor(api: Api, master: boolean) {
|
|
133
|
+
this.#api = api
|
|
134
|
+
this.#master = master
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A row's `select` or `include`, under `path` (`''` at the root). */
|
|
138
|
+
fields(entry: Entry, args: Args, path: string): Read {
|
|
139
|
+
const at = (key: string): string => (path === '' ? key : `${path}.${key}`)
|
|
140
|
+
const { select, include } = args
|
|
141
|
+
if (select !== undefined && include !== undefined) {
|
|
142
|
+
throw new InvalidQuery(
|
|
143
|
+
path,
|
|
144
|
+
`${at('select')} and include cannot be used together.`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
const base = select === undefined ? 'include' : 'select'
|
|
148
|
+
const chosen = select ?? include
|
|
149
|
+
if (chosen === undefined) return { read: {}, shape: defaultShape(entry) }
|
|
150
|
+
if (!isArgs(chosen)) {
|
|
151
|
+
throw new InvalidQuery(at(base), `${at(base)} must be an object.`)
|
|
152
|
+
}
|
|
153
|
+
const kernel: { [field: string]: unknown } =
|
|
154
|
+
base === 'select' ? { id: true } : {}
|
|
155
|
+
const fields: Out[] =
|
|
156
|
+
base === 'select' ? [] : [...defaultShape(entry).fields]
|
|
157
|
+
// The relations' `totalCount`, each the count of its `where` (D-134).
|
|
158
|
+
const counts: { [field: string]: unknown } = {}
|
|
159
|
+
const model = entry.model.name
|
|
160
|
+
for (const [name, value] of Object.entries(chosen)) {
|
|
161
|
+
const where = `${at(base)}.${name}`
|
|
162
|
+
const virtual = readableVirtuals(entry).find((each) => each.name === name)
|
|
163
|
+
if (virtual !== undefined) {
|
|
164
|
+
if (value === false) continue
|
|
165
|
+
const read = this.#virtual(entry, virtual, value, where)
|
|
166
|
+
kernel[name] = read.read
|
|
167
|
+
// `include` reads a default virtual again, where it is.
|
|
168
|
+
const known = fields.findIndex((each) => each.name === name)
|
|
169
|
+
if (known === -1) fields.push(read.field)
|
|
170
|
+
else fields[known] = read.field
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const field = readable(entry).find((each) => each.name === name)
|
|
174
|
+
const isScalar =
|
|
175
|
+
field === undefined ? system.includes(name) : field.kind === 'scalar'
|
|
176
|
+
if (
|
|
177
|
+
(field === undefined && !isScalar) ||
|
|
178
|
+
(base === 'include' && isScalar)
|
|
179
|
+
) {
|
|
180
|
+
throw unknownKey(where, model)
|
|
181
|
+
}
|
|
182
|
+
if (value === false) continue
|
|
183
|
+
if (field === undefined || field.kind === 'scalar') {
|
|
184
|
+
if (value !== true) {
|
|
185
|
+
throw new InvalidQuery(where, `${where} must be a boolean.`)
|
|
186
|
+
}
|
|
187
|
+
kernel[name] = true
|
|
188
|
+
fields.push(scalar(entry, name))
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
const target = entryOf(this.#api, field.target)
|
|
192
|
+
const given = value === true ? {} : value
|
|
193
|
+
if (!isArgs(given)) {
|
|
194
|
+
throw new InvalidQuery(where, `${where} must be an object.`)
|
|
195
|
+
}
|
|
196
|
+
if (field.kind === 'one') {
|
|
197
|
+
this.#keys(given, ['select', 'include'], where, model)
|
|
198
|
+
const nested = this.fields(target, given, where)
|
|
199
|
+
kernel[name] = nested.read
|
|
200
|
+
fields.push({ kind: 'one', name, shape: nested.shape })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
// A list has no count (D-134): `totalCount` is unknown there.
|
|
204
|
+
const pages = this.#api.pagination === 'connection'
|
|
205
|
+
const { totalCount, ...listed } = given
|
|
206
|
+
const many = this.list(target, pages ? listed : given, where)
|
|
207
|
+
const counted = pages && totalCount === true
|
|
208
|
+
if (
|
|
209
|
+
pages &&
|
|
210
|
+
totalCount !== undefined &&
|
|
211
|
+
typeof totalCount !== 'boolean'
|
|
212
|
+
) {
|
|
213
|
+
const key = `${where}.totalCount`
|
|
214
|
+
throw new InvalidQuery(key, `${key} must be true or false.`)
|
|
215
|
+
}
|
|
216
|
+
if (counted) {
|
|
217
|
+
const filter = many.read['where']
|
|
218
|
+
counts[name] = filter === undefined ? true : { where: filter }
|
|
219
|
+
}
|
|
220
|
+
kernel[name] = many.read
|
|
221
|
+
fields.push({
|
|
222
|
+
kind: 'many',
|
|
223
|
+
name,
|
|
224
|
+
shape: many.shape,
|
|
225
|
+
relay: many.relay,
|
|
226
|
+
// A count reads the rows it counts, however few the page returns.
|
|
227
|
+
size: counted ? Math.max(many.size, target.take) : many.size,
|
|
228
|
+
counted,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
if (Object.keys(counts).length > 0) kernel['_count'] = { select: counts }
|
|
232
|
+
return { read: { [base]: kernel }, shape: { fields } }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A virtual's read: `true` or its input, a model's fields too (D-151). */
|
|
236
|
+
#virtual(
|
|
237
|
+
entry: Entry,
|
|
238
|
+
virtual: ExposedVirtual,
|
|
239
|
+
value: unknown,
|
|
240
|
+
where: string,
|
|
241
|
+
): { readonly read: unknown; readonly field: Out } {
|
|
242
|
+
const { name, output } = virtual
|
|
243
|
+
if (output.kind !== 'model') {
|
|
244
|
+
return { read: value, field: scalar(entry, name) }
|
|
245
|
+
}
|
|
246
|
+
if (value !== true && !isArgs(value)) {
|
|
247
|
+
throw new InvalidQuery(where, `${where} must be true or an object.`)
|
|
248
|
+
}
|
|
249
|
+
const { select, include, ...input } = value === true ? {} : value
|
|
250
|
+
const target = entryOf(this.#api, output.name)
|
|
251
|
+
const nested = this.fields(target, { select, include }, where)
|
|
252
|
+
const list = output.form === 'list'
|
|
253
|
+
return {
|
|
254
|
+
read: value === true ? true : { ...input, ...nested.read },
|
|
255
|
+
field: {
|
|
256
|
+
kind: 'returned',
|
|
257
|
+
name,
|
|
258
|
+
owner: `${entry.model.name}.${name}`,
|
|
259
|
+
shape: nested.shape,
|
|
260
|
+
list,
|
|
261
|
+
size: list ? this.#api.take : 1,
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A list's arguments: its page, filter, order and fields. */
|
|
267
|
+
list(
|
|
268
|
+
entry: Entry,
|
|
269
|
+
args: Args,
|
|
270
|
+
path: string,
|
|
271
|
+
): Read & { readonly relay: Relay | undefined; readonly size: number } {
|
|
272
|
+
const at = (key: string): string => (path === '' ? key : `${path}.${key}`)
|
|
273
|
+
const paging = pageArgs(this.#api.pagination)
|
|
274
|
+
const keys = ['select', 'include', 'where', 'orderBy', ...paging]
|
|
275
|
+
this.#keys(args, keys, path, entry.model.name)
|
|
276
|
+
const page = pageOf(this.#api.pagination, entry, args, this.#master, at)
|
|
277
|
+
const { where, orderBy } = args
|
|
278
|
+
const nested = this.fields(entry, args, path)
|
|
279
|
+
const asked =
|
|
280
|
+
this.#api.pagination === 'list'
|
|
281
|
+
? args['take']
|
|
282
|
+
: (args['last'] ?? args['first'])
|
|
283
|
+
return {
|
|
284
|
+
read: {
|
|
285
|
+
...nested.read,
|
|
286
|
+
...page.read,
|
|
287
|
+
...filterOf(this.#api, entry, where, at('where')),
|
|
288
|
+
...(orderBy === undefined || orderBy === null ? {} : { orderBy }),
|
|
289
|
+
},
|
|
290
|
+
shape: { ...nested.shape, sorted: sortDepth(orderBy) },
|
|
291
|
+
relay: page.relay,
|
|
292
|
+
size: sizeOf(entry, asked),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#keys(args: Args, keys: readonly string[], path: string, model: string) {
|
|
297
|
+
for (const key of Object.keys(args)) {
|
|
298
|
+
if (!keys.includes(key)) {
|
|
299
|
+
throw unknownKey(path === '' ? key : `${path}.${key}`, model)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The reader of a request's `select` and `include`. */
|
|
306
|
+
export const readerOf = (api: Api, master: boolean): Reader =>
|
|
307
|
+
new Reader(api, master)
|
|
308
|
+
|
|
309
|
+
/** The levels of relations a row nests, the row counting one. */
|
|
310
|
+
export function depthOf(shape: Shape): number {
|
|
311
|
+
let deepest = shape.sorted ?? 0
|
|
312
|
+
for (const field of shape.fields) {
|
|
313
|
+
if (field.kind !== 'scalar')
|
|
314
|
+
deepest = Math.max(deepest, depthOf(field.shape))
|
|
315
|
+
}
|
|
316
|
+
return 1 + deepest
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The rows the relations of `times` rows may return, as D-107 counts them. */
|
|
320
|
+
export function rowsBelow(shape: Shape, times: number): number {
|
|
321
|
+
let rows = 0
|
|
322
|
+
for (const field of shape.fields) {
|
|
323
|
+
if (field.kind === 'scalar') continue
|
|
324
|
+
const each = field.kind === 'one' ? times : times * field.size
|
|
325
|
+
rows += each
|
|
326
|
+
if (each > 0) rows += rowsBelow(field.shape, each)
|
|
327
|
+
}
|
|
328
|
+
return rows
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The count the kernel read of a row's relation (D-134). */
|
|
332
|
+
function countOf(row: Row, name: string): unknown {
|
|
333
|
+
const counts = row['_count']
|
|
334
|
+
return isArgs(counts) ? counts[name] : undefined
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A virtual's rows of a model, at most `take` under a relation (D-109). */
|
|
338
|
+
function returned(
|
|
339
|
+
field: Extract<Out, { readonly kind: 'returned' }>,
|
|
340
|
+
value: unknown,
|
|
341
|
+
): unknown {
|
|
342
|
+
if (!field.list) {
|
|
343
|
+
const row = rowOf(value)
|
|
344
|
+
return row === undefined ? null : shaped(row, field.shape)
|
|
345
|
+
}
|
|
346
|
+
const rows = rowsOf(value)
|
|
347
|
+
const relates = field.shape.fields.some((each) => each.kind !== 'scalar')
|
|
348
|
+
if (rows.length > field.size && relates) {
|
|
349
|
+
throw new RangeError(
|
|
350
|
+
`${field.owner} returned ${rows.length} rows; a list counts take (${field.size}) rows, so it holds no more under a selected relation.`,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
return rows.map((row) => shaped(row, field.shape))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A row as GraphQL returns it: its fields, its relations' pages. */
|
|
357
|
+
export function shaped(row: Row, shape: Shape): { [field: string]: unknown } {
|
|
358
|
+
const out: { [field: string]: unknown } = {}
|
|
359
|
+
for (const field of shape.fields) {
|
|
360
|
+
const value = row[field.name]
|
|
361
|
+
if (field.kind === 'scalar') {
|
|
362
|
+
out[field.name] =
|
|
363
|
+
value === undefined || value === null
|
|
364
|
+
? null
|
|
365
|
+
: field.toJSON === undefined
|
|
366
|
+
? value
|
|
367
|
+
: field.toJSON(value)
|
|
368
|
+
} else if (field.kind === 'one') {
|
|
369
|
+
const target = rowOf(value)
|
|
370
|
+
out[field.name] =
|
|
371
|
+
target === undefined ? null : shaped(target, field.shape)
|
|
372
|
+
} else if (field.kind === 'returned') {
|
|
373
|
+
out[field.name] = returned(field, value)
|
|
374
|
+
} else if (value === undefined || value === null) {
|
|
375
|
+
// Absent when the field's read rule hides it (D-44).
|
|
376
|
+
out[field.name] = null
|
|
377
|
+
} else {
|
|
378
|
+
const rows = rowsOf(value)
|
|
379
|
+
const node = (each: Row) => shaped(each, field.shape)
|
|
380
|
+
if (field.relay === undefined) {
|
|
381
|
+
out[field.name] = rows.map(node)
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
const page = connection(rows, field.relay, node)
|
|
385
|
+
out[field.name] = field.counted
|
|
386
|
+
? { ...page, totalCount: countOf(row, field.name) }
|
|
387
|
+
: page
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return out
|
|
391
|
+
}
|
package/src/functions.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The application's functions (D-129): a query at `GET /api/functions/:name`,
|
|
2
|
+
// its input keys as query parameters, a mutation at `POST`, its body as its
|
|
3
|
+
// input, each answered with the output GraphQL's field returns; a live query
|
|
4
|
+
// over Server-Sent Events, with `Accept: text/event-stream` (D-154).
|
|
5
|
+
import type { Routes } from '@ultimabase/http'
|
|
6
|
+
import { clientOf, validate } from '@ultimabase/http'
|
|
7
|
+
import type { Node } from '@ultimabase/http/internal'
|
|
8
|
+
import { isNode, jsonSchemaOf, rowOf } from '@ultimabase/http/internal'
|
|
9
|
+
import type { Caller, Client, ExposedFunction, ExposedOutput } from 'ultimabase'
|
|
10
|
+
import { Refused } from 'ultimabase'
|
|
11
|
+
import type { Api, Entry } from './entries.ts'
|
|
12
|
+
import { checkName, entryOf } from './entries.ts'
|
|
13
|
+
import { defaultShape, defaultVirtuals, shaped } from './fields.ts'
|
|
14
|
+
import { acceptsNull, isText, resolved } from './json-schema.ts'
|
|
15
|
+
import { anyBody } from './models.ts'
|
|
16
|
+
import type { Accepted } from './params.ts'
|
|
17
|
+
import { liveValue, wantsEvents } from './live.ts'
|
|
18
|
+
import { paramsOf } from './params.ts'
|
|
19
|
+
import type { Live } from './streams.ts'
|
|
20
|
+
|
|
21
|
+
/** A function's key of `.with` (D-129): its route's name, or hidden. */
|
|
22
|
+
export type RestFunctionWith = { readonly name?: string } | false
|
|
23
|
+
|
|
24
|
+
const none: Accepted = { json: [], text: [] }
|
|
25
|
+
|
|
26
|
+
/** A function REST routes, named, and its input's JSON Schema. */
|
|
27
|
+
export type Routed = {
|
|
28
|
+
readonly fn: ExposedFunction<RestFunctionWith>
|
|
29
|
+
readonly owner: string
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly input: Node | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A GET's parameters: an object input's keys, as GraphQL's arguments, or
|
|
36
|
+
* one `input`; a string as is, any other value as JSON.
|
|
37
|
+
*/
|
|
38
|
+
export function getParams(root: Node | undefined): {
|
|
39
|
+
readonly accepted: Accepted
|
|
40
|
+
readonly flat: boolean
|
|
41
|
+
} {
|
|
42
|
+
if (root === undefined) return { accepted: none, flat: false }
|
|
43
|
+
const object = resolved(root, root)
|
|
44
|
+
const { properties } = object
|
|
45
|
+
if (object['type'] === 'object' && isNode(properties)) {
|
|
46
|
+
const keys = Object.entries(properties)
|
|
47
|
+
const text = (node: unknown) => isNode(node) && isText(node, root)
|
|
48
|
+
return {
|
|
49
|
+
accepted: {
|
|
50
|
+
json: keys.filter(([, node]) => !text(node)).map(([key]) => key),
|
|
51
|
+
text: keys.filter(([, node]) => text(node)).map(([key]) => key),
|
|
52
|
+
},
|
|
53
|
+
flat: true,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const as = isText(root, root) ? 'text' : 'json'
|
|
57
|
+
return { accepted: { ...none, [as]: ['input'] }, flat: false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The rows of each model whose default virtuals are read again. */
|
|
61
|
+
type Pending = Map<Entry, { id: string; copy: { [field: string]: unknown } }[]>
|
|
62
|
+
|
|
63
|
+
const nonNull = (where: string): TypeError =>
|
|
64
|
+
new TypeError(`Cannot return null for non-nullable field ${where}.`)
|
|
65
|
+
|
|
66
|
+
/** A value as GraphQL returns a function's output, a model's row its scalars. */
|
|
67
|
+
function outputOf(
|
|
68
|
+
api: Api,
|
|
69
|
+
output: ExposedOutput,
|
|
70
|
+
value: unknown,
|
|
71
|
+
where: string,
|
|
72
|
+
pending: Pending,
|
|
73
|
+
): unknown {
|
|
74
|
+
if (output.kind === 'schema') return value ?? null
|
|
75
|
+
if (value === null || value === undefined) {
|
|
76
|
+
if (output.form === 'nullable') return null
|
|
77
|
+
throw nonNull(where)
|
|
78
|
+
}
|
|
79
|
+
if (output.form === 'list') {
|
|
80
|
+
const items: readonly unknown[] = Array.isArray(value) ? value : []
|
|
81
|
+
const item = { ...output, form: 'one' } as const
|
|
82
|
+
return items.map((each) => outputOf(api, item, each, where, pending))
|
|
83
|
+
}
|
|
84
|
+
if (output.kind === 'model') {
|
|
85
|
+
const row = rowOf(value)
|
|
86
|
+
if (row === undefined) {
|
|
87
|
+
throw new TypeError(`A ${output.name} was returned without its id.`)
|
|
88
|
+
}
|
|
89
|
+
const entry = entryOf(api, output.name)
|
|
90
|
+
const copy = shaped(row, defaultShape(entry))
|
|
91
|
+
if (defaultVirtuals(entry).length > 0) {
|
|
92
|
+
pending.set(entry, [...(pending.get(entry) ?? []), { id: row.id, copy }])
|
|
93
|
+
}
|
|
94
|
+
return copy
|
|
95
|
+
}
|
|
96
|
+
const given: Node = isNode(value) ? value : {}
|
|
97
|
+
const { type } = output
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
[...type.fields].map(([name, field]) => [
|
|
100
|
+
name,
|
|
101
|
+
outputOf(api, field, given[name], `${type.name}.${name}`, pending),
|
|
102
|
+
]),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The default virtuals of a function's rows, read by id as the caller (D-151). */
|
|
107
|
+
async function withVirtuals(pending: Pending, as: Caller): Promise<void> {
|
|
108
|
+
for (const [entry, rows] of pending) {
|
|
109
|
+
const names = defaultVirtuals(entry)
|
|
110
|
+
const ids = [...new Set(rows.map(({ id }) => id))]
|
|
111
|
+
const found = await entry.exposed.queries.findMany(
|
|
112
|
+
{
|
|
113
|
+
where: { id: { in: ids } },
|
|
114
|
+
take: ids.length,
|
|
115
|
+
select: Object.fromEntries([
|
|
116
|
+
['id', true],
|
|
117
|
+
...names.map((name) => [name, true]),
|
|
118
|
+
]),
|
|
119
|
+
},
|
|
120
|
+
{ as },
|
|
121
|
+
)
|
|
122
|
+
const byId = new Map(found.map((row) => [row.id, row]))
|
|
123
|
+
for (const { id, copy } of rows) {
|
|
124
|
+
// A row the caller cannot read has none (D-109).
|
|
125
|
+
for (const name of names) copy[name] = byId.get(id)?.[name] ?? null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Each routed function under `${base}/functions` (D-109, D-113). */
|
|
131
|
+
export function functionRoutes(
|
|
132
|
+
app: Routes,
|
|
133
|
+
api: Api,
|
|
134
|
+
functions: readonly ExposedFunction<RestFunctionWith>[],
|
|
135
|
+
base: string,
|
|
136
|
+
live: Live | undefined,
|
|
137
|
+
): readonly Routed[] {
|
|
138
|
+
const routed: Routed[] = []
|
|
139
|
+
const owners = new Map<string, string>()
|
|
140
|
+
for (const fn of functions) {
|
|
141
|
+
if (fn.with === false) continue
|
|
142
|
+
// A plugin's function is named with its plugin (D-113).
|
|
143
|
+
const owner = fn.plugin === undefined ? fn.name : `${fn.plugin}.${fn.name}`
|
|
144
|
+
const name = checkName(fn.with?.name ?? fn.name, owner)
|
|
145
|
+
const other = owners.get(name)
|
|
146
|
+
if (other !== undefined) {
|
|
147
|
+
throw new Refused(
|
|
148
|
+
`${owner}: the route functions/${name} is ${other}'s; set .with({ rest: { name } }).`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
owners.set(name, owner)
|
|
152
|
+
// GraphQL types both sides from their exports, and refuses without (D-45).
|
|
153
|
+
const { input, output } = fn
|
|
154
|
+
const root =
|
|
155
|
+
input === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: jsonSchemaOf(input, 'input', `${owner}: input`)
|
|
158
|
+
routed.push({ fn, owner, name, input: root })
|
|
159
|
+
const nullable =
|
|
160
|
+
output?.kind === 'schema'
|
|
161
|
+
? acceptsNull(jsonSchemaOf(output.schema, 'output', `${owner}: output`))
|
|
162
|
+
: output?.form === 'nullable'
|
|
163
|
+
const answer = async (as: Caller, client: Client, sent: unknown) => {
|
|
164
|
+
const value = await fn.run(sent, { as }, client)
|
|
165
|
+
if (output === undefined) return true
|
|
166
|
+
if (value === undefined && !nullable) {
|
|
167
|
+
throw new TypeError(
|
|
168
|
+
`${fn.name} returned undefined; make its output .nullable() and return null.`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
const pending: Pending = new Map()
|
|
172
|
+
const answered = outputOf(api, output, value, fn.name, pending)
|
|
173
|
+
await withVirtuals(pending, as)
|
|
174
|
+
return answered
|
|
175
|
+
}
|
|
176
|
+
const path = `${base}/functions/${name}`
|
|
177
|
+
if (fn.query) {
|
|
178
|
+
const { accepted, flat } = getParams(root)
|
|
179
|
+
app.get(path, async (c) => {
|
|
180
|
+
const params = paramsOf(c.req.url, accepted)
|
|
181
|
+
const { client, sent } = clientOf(c)
|
|
182
|
+
const given = flat ? params : params['input']
|
|
183
|
+
if (wantsEvents(c.req.raw)) {
|
|
184
|
+
const compute = async (as: Caller) => answer(as, client, given)
|
|
185
|
+
return sent(await liveValue(live, owner, c.req.raw, compute))
|
|
186
|
+
}
|
|
187
|
+
return sent(c.json(await answer(c.var.caller, client, given)))
|
|
188
|
+
})
|
|
189
|
+
} else if (input === undefined) {
|
|
190
|
+
app.post(path, async (c) => {
|
|
191
|
+
paramsOf(c.req.url, none)
|
|
192
|
+
const { client, sent } = clientOf(c)
|
|
193
|
+
return sent(c.json(await answer(c.var.caller, client, undefined)))
|
|
194
|
+
})
|
|
195
|
+
} else {
|
|
196
|
+
app.post(path, validate('json', anyBody), async (c) => {
|
|
197
|
+
paramsOf(c.req.url, none)
|
|
198
|
+
const { client, sent } = clientOf(c)
|
|
199
|
+
const body: unknown = c.req.valid('json')
|
|
200
|
+
return sent(c.json(await answer(c.var.caller, client, body)))
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return routed
|
|
205
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `restPlugin()` (D-129): GraphQL's arguments and responses over resource
|
|
2
|
+
// routes, mounted on the HTTP plugin and run as each request's caller; with
|
|
3
|
+
// the realtime plugin, its live models' reads as Server-Sent Events (D-154).
|
|
4
|
+
import type { Http } from '@ultimabase/http'
|
|
5
|
+
import { httpPlugin, routes } from '@ultimabase/http'
|
|
6
|
+
import type { Realtime } from '@ultimabase/realtime'
|
|
7
|
+
import { realtimePlugin } from '@ultimabase/realtime'
|
|
8
|
+
import type { PluginOf, PluginTypes, UseOf } from 'ultimabase'
|
|
9
|
+
import { definePlugin } from 'ultimabase'
|
|
10
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
11
|
+
import type { RestWith } from './entries.ts'
|
|
12
|
+
import { entriesOf } from './entries.ts'
|
|
13
|
+
import type { RestFunctionWith } from './functions.ts'
|
|
14
|
+
import { functionRoutes } from './functions.ts'
|
|
15
|
+
import { modelRoutes } from './models.ts'
|
|
16
|
+
import { openapiOf } from './openapi.ts'
|
|
17
|
+
|
|
18
|
+
export type { RestWith } from './entries.ts'
|
|
19
|
+
export type { RestFunctionWith } from './functions.ts'
|
|
20
|
+
|
|
21
|
+
/** What `restPlugin` takes: `graphqlPlugin`'s options, by name (D-129). */
|
|
22
|
+
export interface RestOptions {
|
|
23
|
+
/** `/api` by default. */
|
|
24
|
+
readonly path?: string
|
|
25
|
+
/** Relay connections, or `take` and `skip` lists. */
|
|
26
|
+
readonly pagination?: 'connection' | 'list'
|
|
27
|
+
/** The rows a list returns when none is asked for; 100 by default (D-55). */
|
|
28
|
+
readonly take?: number
|
|
29
|
+
/** The rows a caller other than Master may ask for; 100 by default. */
|
|
30
|
+
readonly maxTake?: number
|
|
31
|
+
/** The deepest a request of a caller other than Master nests; 10 by default. */
|
|
32
|
+
readonly maxDepth?: number
|
|
33
|
+
/**
|
|
34
|
+
* The most rows a request of a caller other than Master may return, each
|
|
35
|
+
* list times the lists above it; 20 000 by default.
|
|
36
|
+
*/
|
|
37
|
+
readonly maxRows?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The REST plugin's types (D-57). */
|
|
41
|
+
export interface Rest extends PluginTypes {
|
|
42
|
+
readonly name: 'rest'
|
|
43
|
+
readonly appOptions: RestOptions
|
|
44
|
+
readonly requires: Http
|
|
45
|
+
readonly optional: Realtime
|
|
46
|
+
readonly with: RestWith
|
|
47
|
+
readonly functionWith: RestFunctionWith
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const baseOf = (options: RestOptions): string =>
|
|
51
|
+
(options.path ?? '/api').replace(/\/+$/, '')
|
|
52
|
+
|
|
53
|
+
// What ends each running application's event streams, by its plugin's context.
|
|
54
|
+
const streams = new WeakMap<object, Set<() => void>>()
|
|
55
|
+
|
|
56
|
+
const plugin = definePlugin<Rest>({
|
|
57
|
+
name: 'rest',
|
|
58
|
+
manifest,
|
|
59
|
+
requires: { http: httpPlugin },
|
|
60
|
+
optional: { realtime: realtimePlugin },
|
|
61
|
+
api: (context) => {
|
|
62
|
+
const { plugins, models, functions, options, logger } = context
|
|
63
|
+
const { realtime } = plugins
|
|
64
|
+
const ends = new Set<() => void>()
|
|
65
|
+
streams.set(context, ends)
|
|
66
|
+
const live = realtime && {
|
|
67
|
+
realtime,
|
|
68
|
+
streams: ends,
|
|
69
|
+
kept: new Map(),
|
|
70
|
+
open: new Map(),
|
|
71
|
+
logger,
|
|
72
|
+
}
|
|
73
|
+
const api = {
|
|
74
|
+
entries: entriesOf(models, options),
|
|
75
|
+
pagination: options.pagination ?? 'connection',
|
|
76
|
+
take: options.take ?? 100,
|
|
77
|
+
maxDepth: options.maxDepth ?? 10,
|
|
78
|
+
maxRows: options.maxRows ?? 20_000,
|
|
79
|
+
}
|
|
80
|
+
const base = baseOf(options)
|
|
81
|
+
const app = routes()
|
|
82
|
+
const routed = functionRoutes(app, api, functions, base, live)
|
|
83
|
+
for (const entry of api.entries.values()) {
|
|
84
|
+
if (entry.visible) modelRoutes(app, api, entry, base, live)
|
|
85
|
+
}
|
|
86
|
+
// Built at startup, so a name clash refuses to start (D-144).
|
|
87
|
+
const document = openapiOf(api, routed, base, manifest.version)
|
|
88
|
+
app.get(`${base}/openapi.json`, (c) => c.json(document))
|
|
89
|
+
plugins.http.mount(app)
|
|
90
|
+
},
|
|
91
|
+
start: ({ plugins, options, logger }) => {
|
|
92
|
+
if (process.env['NODE_ENV'] === 'production') return
|
|
93
|
+
logger.info(`REST on ${plugins.http.url()}${baseOf(options)}.`)
|
|
94
|
+
},
|
|
95
|
+
// Before the HTTP plugin's stop, which waits for the streams to end.
|
|
96
|
+
stop: (context) => {
|
|
97
|
+
for (const end of streams.get(context) ?? []) end()
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** The application's REST API, served by `httpPlugin` (D-129). */
|
|
102
|
+
export const restPlugin: PluginOf<Rest> = plugin.plugin
|
|
103
|
+
|
|
104
|
+
/** A model's `.with({ rest })`, from its `use` (D-129). */
|
|
105
|
+
export const rest: UseOf<Rest> = plugin.use
|