@protobase/server 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/LICENSE +202 -0
- package/dist/aggregate-service.js +135 -0
- package/dist/audit/console-queue.d.ts +5 -0
- package/dist/audit/console-queue.js +5 -0
- package/dist/auth/jwt.d.ts +21 -0
- package/dist/auth/jwt.js +49 -0
- package/dist/batch/refs.js +31 -0
- package/dist/batch/reorder.js +79 -0
- package/dist/batch/run-batch.js +105 -0
- package/dist/batch/schema.js +51 -0
- package/dist/better-auth/authenticator.d.ts +22 -0
- package/dist/better-auth/authenticator.js +21 -0
- package/dist/better-auth/create-auth.d.ts +1252 -0
- package/dist/better-auth/create-auth.js +104 -0
- package/dist/better-auth/jwks-cache.d.ts +9 -0
- package/dist/better-auth/jwks-cache.js +31 -0
- package/dist/better-auth/password-reset.js +32 -0
- package/dist/better-auth/status-route.js +19 -0
- package/dist/better-auth/tokens.d.ts +18 -0
- package/dist/better-auth/tokens.js +37 -0
- package/dist/better-auth/users.d.ts +50 -0
- package/dist/better-auth/users.js +111 -0
- package/dist/check-shell.js +17 -0
- package/dist/check-views.js +45 -0
- package/dist/create-admin.d.ts +23 -0
- package/dist/create-admin.js +85 -0
- package/dist/error-handler.js +44 -0
- package/dist/etag.js +22 -0
- package/dist/filter-input.js +28 -0
- package/dist/filter-shape.js +25 -0
- package/dist/hash.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/key.js +22 -0
- package/dist/list-service.js +96 -0
- package/dist/mail/mail-settings.js +26 -0
- package/dist/mail/smtp-mailer.d.ts +11 -0
- package/dist/mail/smtp-mailer.js +17 -0
- package/dist/meta.js +63 -0
- package/dist/openapi/components.js +331 -0
- package/dist/openapi/document.js +74 -0
- package/dist/openapi/naming.js +4 -0
- package/dist/openapi/resource-paths.js +417 -0
- package/dist/openapi/schemas.js +93 -0
- package/dist/params.js +48 -0
- package/dist/problem.d.ts +17 -0
- package/dist/problem.js +43 -0
- package/dist/read-service.js +65 -0
- package/dist/records.js +66 -0
- package/dist/registry.js +44 -0
- package/dist/request-access.js +97 -0
- package/dist/resource-source.d.ts +35 -0
- package/dist/resource-source.js +22 -0
- package/dist/reveal-service.js +43 -0
- package/dist/routes/batch.js +21 -0
- package/dist/routes/collection.js +63 -0
- package/dist/routes/docs.js +20 -0
- package/dist/routes/meta.js +22 -0
- package/dist/routes/preferences.js +12 -0
- package/dist/routes/record.js +97 -0
- package/dist/routes/target.js +18 -0
- package/dist/row-permissions.js +69 -0
- package/dist/rows.js +12 -0
- package/dist/scan-guard.js +72 -0
- package/dist/sql-parts.js +32 -0
- package/dist/tenant.js +10 -0
- package/dist/transactions.js +14 -0
- package/dist/types.d.ts +97 -0
- package/dist/validate-body.js +73 -0
- package/dist/view-access.js +54 -0
- package/dist/view-related.js +59 -0
- package/dist/write-pipeline.js +94 -0
- package/dist/write-shared.js +52 -0
- package/dist/write-update.js +45 -0
- package/package.json +39 -3
- package/README.md +0 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { batchSchemas, errorResponses, parameters, problemSchema, searchBody } from "./components.js";
|
|
2
|
+
import { resourcePaths } from "./resource-paths.js";
|
|
3
|
+
import { resourceSchemas } from "./schemas.js";
|
|
4
|
+
//#region src/openapi/document.ts
|
|
5
|
+
/** The OpenAPI 3.1 document for one caller, generated from the resource models they may see and their zod record schemas. */
|
|
6
|
+
const openApiDocument = (accesses, basePath, systemPath) => ({
|
|
7
|
+
openapi: "3.1.0",
|
|
8
|
+
info: {
|
|
9
|
+
title: "Protobase API",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
description: "Admin API for an existing Postgres database. Follows Google's API design conventions ([AIP](https://google.aip.dev)) and reports errors as RFC 9457 `application/problem+json`."
|
|
12
|
+
},
|
|
13
|
+
servers: [{ url: basePath }],
|
|
14
|
+
security: [{ bearer: [] }],
|
|
15
|
+
tags: accesses.map(({ entry }) => ({ name: entry.name })),
|
|
16
|
+
paths: {
|
|
17
|
+
"/meta": {
|
|
18
|
+
servers: [{ url: systemPath || "/" }],
|
|
19
|
+
get: {
|
|
20
|
+
operationId: "get_meta",
|
|
21
|
+
summary: "Resource and view models",
|
|
22
|
+
description: "ETag from a content hash; send If-None-Match for a 304.",
|
|
23
|
+
responses: {
|
|
24
|
+
200: { description: "The models" },
|
|
25
|
+
304: { description: "Not modified" },
|
|
26
|
+
401: { $ref: "#/components/responses/Unauthorized" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
[`/${basePath.slice(basePath.lastIndexOf("/") + 1)}:batchWrite`]: {
|
|
31
|
+
servers: [{ url: basePath.slice(0, basePath.lastIndexOf("/")) || "/" }],
|
|
32
|
+
post: {
|
|
33
|
+
operationId: "batch_write",
|
|
34
|
+
summary: "Write many records in one transaction",
|
|
35
|
+
description: "Ordered create, update, delete and reorder operations, at most 500, across resources. Each runs through the normal write pipeline (validation, access with row filters and record rules, tenant, ETags, hooks); the first failure rolls everything back and the problem names it in `operation` (its index), `op` and `resource`. A stale ETag is a 412 for the whole batch. `reorder` moves only the records that change, first out of the way (above the highest value in the column, or below the lowest when a check constraint refuses that), so a unique index such as (invoice_id, position) is never violated half way. `{ \"$ref\": name }` points at a record created earlier by `ref`.",
|
|
36
|
+
requestBody: {
|
|
37
|
+
required: true,
|
|
38
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/BatchRequest" } } }
|
|
39
|
+
},
|
|
40
|
+
responses: {
|
|
41
|
+
200: {
|
|
42
|
+
description: "Every result, in order",
|
|
43
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/BatchResponse" } } }
|
|
44
|
+
},
|
|
45
|
+
400: { $ref: "#/components/responses/BadRequest" },
|
|
46
|
+
401: { $ref: "#/components/responses/Unauthorized" },
|
|
47
|
+
403: { $ref: "#/components/responses/Forbidden" },
|
|
48
|
+
404: { $ref: "#/components/responses/NotFound" },
|
|
49
|
+
409: { $ref: "#/components/responses/Conflict" },
|
|
50
|
+
412: { $ref: "#/components/responses/PreconditionFailed" },
|
|
51
|
+
428: { $ref: "#/components/responses/PreconditionRequired" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
...Object.assign({}, ...accesses.map(resourcePaths))
|
|
56
|
+
},
|
|
57
|
+
components: {
|
|
58
|
+
securitySchemes: { bearer: {
|
|
59
|
+
type: "http",
|
|
60
|
+
scheme: "bearer",
|
|
61
|
+
bearerFormat: "JWT"
|
|
62
|
+
} },
|
|
63
|
+
parameters,
|
|
64
|
+
responses: errorResponses,
|
|
65
|
+
schemas: {
|
|
66
|
+
Problem: problemSchema,
|
|
67
|
+
SearchBody: searchBody,
|
|
68
|
+
...batchSchemas,
|
|
69
|
+
...Object.assign({}, ...accesses.map(resourceSchemas))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
//#endregion
|
|
74
|
+
export { openApiDocument };
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { pascal } from "./naming.js";
|
|
2
|
+
//#region src/openapi/resource-paths.ts
|
|
3
|
+
const param = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
4
|
+
const error = (name) => ({ $ref: `#/components/responses/${name}` });
|
|
5
|
+
const json = (schema) => ({ "application/json": { schema } });
|
|
6
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
7
|
+
const listParameters = (entry) => [
|
|
8
|
+
"filter",
|
|
9
|
+
"orderBy",
|
|
10
|
+
"pageSize",
|
|
11
|
+
"pageToken",
|
|
12
|
+
"fields",
|
|
13
|
+
"count",
|
|
14
|
+
...entry.model.softDelete ? ["showDeleted"] : []
|
|
15
|
+
].map(param);
|
|
16
|
+
const field = (description) => ({
|
|
17
|
+
name: "field",
|
|
18
|
+
in: "query",
|
|
19
|
+
required: true,
|
|
20
|
+
description,
|
|
21
|
+
schema: { type: "string" }
|
|
22
|
+
});
|
|
23
|
+
const query = (name, description, schema = { type: "string" }, required = false) => ({
|
|
24
|
+
name,
|
|
25
|
+
in: "query",
|
|
26
|
+
required,
|
|
27
|
+
description,
|
|
28
|
+
schema
|
|
29
|
+
});
|
|
30
|
+
const record = (name, entry) => ({
|
|
31
|
+
description: `The ${entry.name} record. The ETag header is its version.`,
|
|
32
|
+
headers: { ETag: { schema: { type: "string" } } },
|
|
33
|
+
content: json(ref(name))
|
|
34
|
+
});
|
|
35
|
+
const page = (name, seek = false) => json({
|
|
36
|
+
type: "object",
|
|
37
|
+
required: [
|
|
38
|
+
"items",
|
|
39
|
+
"next_page_token",
|
|
40
|
+
"total_size_estimate"
|
|
41
|
+
],
|
|
42
|
+
properties: {
|
|
43
|
+
items: {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: ref(name)
|
|
46
|
+
},
|
|
47
|
+
next_page_token: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Empty on the last page"
|
|
50
|
+
},
|
|
51
|
+
...seek && { prev_page_token: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Empty on the first page"
|
|
54
|
+
} },
|
|
55
|
+
total_size_estimate: {
|
|
56
|
+
type: "integer",
|
|
57
|
+
description: "The planner's estimate"
|
|
58
|
+
},
|
|
59
|
+
total_size: {
|
|
60
|
+
type: "integer",
|
|
61
|
+
description: "Only with count=exact"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const needs = {
|
|
66
|
+
list: "list",
|
|
67
|
+
search: "list",
|
|
68
|
+
facets: "list",
|
|
69
|
+
series: "list",
|
|
70
|
+
histogram: "list",
|
|
71
|
+
seek: "list",
|
|
72
|
+
get: "read",
|
|
73
|
+
reveal: "read",
|
|
74
|
+
create: "create",
|
|
75
|
+
update: "update",
|
|
76
|
+
undelete: "update",
|
|
77
|
+
delete: "delete"
|
|
78
|
+
};
|
|
79
|
+
const withoutDenied = (paths, access) => Object.fromEntries(Object.entries(paths).flatMap(([path, methods]) => {
|
|
80
|
+
const allowed = Object.entries(methods).filter(([, operation]) => access.resolved.operations[needs[String(operation.operationId).split("_")[0]]]);
|
|
81
|
+
return allowed.length > 0 ? [[path, Object.fromEntries(allowed)]] : [];
|
|
82
|
+
}));
|
|
83
|
+
const auth = { 401: error("Unauthorized") };
|
|
84
|
+
/** The paths of one resource: AIP-132 list, AIP-131 get, AIP-133 create, AIP-134 update, AIP-135 delete, and the `:` helpers (AIP-136). */
|
|
85
|
+
const resourcePaths = (access) => {
|
|
86
|
+
const { entry } = access;
|
|
87
|
+
const name = pascal(entry.name);
|
|
88
|
+
const tag = [entry.name];
|
|
89
|
+
const tenantNote = entry.model.tenant ? " Rows of other tenants are never visible." : "";
|
|
90
|
+
const softNote = entry.model.softDelete ? " Soft-deleted rows are hidden." : "";
|
|
91
|
+
const keyErrors = {
|
|
92
|
+
400: error("BadRequest"),
|
|
93
|
+
...auth,
|
|
94
|
+
404: error("NotFound")
|
|
95
|
+
};
|
|
96
|
+
const sensitive = Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name);
|
|
97
|
+
const paths = {
|
|
98
|
+
[`/${entry.name}`]: {
|
|
99
|
+
get: {
|
|
100
|
+
operationId: `list_${entry.name}`,
|
|
101
|
+
tags: tag,
|
|
102
|
+
summary: `List ${entry.name}`,
|
|
103
|
+
description: `Keyset paginated list.${tenantNote}${softNote}`,
|
|
104
|
+
parameters: listParameters(entry),
|
|
105
|
+
responses: {
|
|
106
|
+
200: {
|
|
107
|
+
description: "A page",
|
|
108
|
+
content: page(name)
|
|
109
|
+
},
|
|
110
|
+
400: error("BadRequest"),
|
|
111
|
+
...auth
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
post: {
|
|
115
|
+
operationId: `create_${entry.name}`,
|
|
116
|
+
tags: tag,
|
|
117
|
+
summary: `Create a ${entry.name} record`,
|
|
118
|
+
parameters: [param("prefer")],
|
|
119
|
+
requestBody: {
|
|
120
|
+
required: true,
|
|
121
|
+
content: json(ref(`${name}Create`))
|
|
122
|
+
},
|
|
123
|
+
responses: {
|
|
124
|
+
201: {
|
|
125
|
+
description: "Created; Location and ETag headers",
|
|
126
|
+
content: json(ref(name))
|
|
127
|
+
},
|
|
128
|
+
400: error("BadRequest"),
|
|
129
|
+
...auth,
|
|
130
|
+
403: error("Forbidden"),
|
|
131
|
+
409: error("Conflict")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[`/${entry.name}:search`]: { post: {
|
|
136
|
+
operationId: `search_${entry.name}`,
|
|
137
|
+
tags: tag,
|
|
138
|
+
summary: `List ${entry.name} with a JSON body`,
|
|
139
|
+
requestBody: {
|
|
140
|
+
required: true,
|
|
141
|
+
content: json({ $ref: "#/components/schemas/SearchBody" })
|
|
142
|
+
},
|
|
143
|
+
responses: {
|
|
144
|
+
200: {
|
|
145
|
+
description: "A page",
|
|
146
|
+
content: page(name)
|
|
147
|
+
},
|
|
148
|
+
400: error("BadRequest"),
|
|
149
|
+
...auth
|
|
150
|
+
}
|
|
151
|
+
} },
|
|
152
|
+
[`/${entry.name}/{key}`]: {
|
|
153
|
+
get: {
|
|
154
|
+
operationId: `get_${entry.name}`,
|
|
155
|
+
tags: tag,
|
|
156
|
+
summary: `Get a ${entry.name} record`,
|
|
157
|
+
parameters: [
|
|
158
|
+
param("key"),
|
|
159
|
+
param("fields"),
|
|
160
|
+
...entry.model.softDelete ? [param("showDeleted")] : []
|
|
161
|
+
],
|
|
162
|
+
responses: {
|
|
163
|
+
200: record(name, entry),
|
|
164
|
+
...keyErrors
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
patch: {
|
|
168
|
+
operationId: `update_${entry.name}`,
|
|
169
|
+
tags: tag,
|
|
170
|
+
summary: `Update a ${entry.name} record`,
|
|
171
|
+
description: "Only the fields in the body change. Needs If-Match; a stale ETag is a 412.",
|
|
172
|
+
parameters: [
|
|
173
|
+
param("key"),
|
|
174
|
+
param("ifMatch"),
|
|
175
|
+
param("prefer")
|
|
176
|
+
],
|
|
177
|
+
requestBody: {
|
|
178
|
+
required: true,
|
|
179
|
+
content: json(ref(`${name}Update`))
|
|
180
|
+
},
|
|
181
|
+
responses: {
|
|
182
|
+
200: record(name, entry),
|
|
183
|
+
204: { description: "With Prefer: return=minimal" },
|
|
184
|
+
...keyErrors,
|
|
185
|
+
403: error("Forbidden"),
|
|
186
|
+
409: error("Conflict"),
|
|
187
|
+
412: error("PreconditionFailed"),
|
|
188
|
+
428: error("PreconditionRequired")
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
delete: {
|
|
192
|
+
operationId: `delete_${entry.name}`,
|
|
193
|
+
tags: tag,
|
|
194
|
+
summary: `Delete a ${entry.name} record`,
|
|
195
|
+
description: entry.model.softDelete ? "Soft delete: the record is hidden and can be restored." : "Removes the row.",
|
|
196
|
+
parameters: [
|
|
197
|
+
param("key"),
|
|
198
|
+
param("ifMatchOptional"),
|
|
199
|
+
param("prefer")
|
|
200
|
+
],
|
|
201
|
+
responses: {
|
|
202
|
+
204: { description: "Deleted" },
|
|
203
|
+
...keyErrors,
|
|
204
|
+
403: error("Forbidden"),
|
|
205
|
+
409: error("Conflict"),
|
|
206
|
+
412: error("PreconditionFailed")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
...entry.model.softDelete && { [`/${entry.name}/{key}:undelete`]: { post: {
|
|
211
|
+
operationId: `undelete_${entry.name}`,
|
|
212
|
+
tags: tag,
|
|
213
|
+
summary: `Restore a deleted ${entry.name} record`,
|
|
214
|
+
description: "[AIP-164](https://google.aip.dev/164): restores a deleted record. A record that is not deleted is a 409.",
|
|
215
|
+
parameters: [
|
|
216
|
+
param("key"),
|
|
217
|
+
param("ifMatchOptional"),
|
|
218
|
+
param("prefer")
|
|
219
|
+
],
|
|
220
|
+
responses: {
|
|
221
|
+
200: record(name, entry),
|
|
222
|
+
204: { description: "With Prefer: return=minimal" },
|
|
223
|
+
...keyErrors,
|
|
224
|
+
403: error("Forbidden"),
|
|
225
|
+
409: error("Conflict"),
|
|
226
|
+
412: error("PreconditionFailed")
|
|
227
|
+
}
|
|
228
|
+
} } },
|
|
229
|
+
...sensitive.length > 0 && { [`/${entry.name}/{key}:reveal`]: { post: {
|
|
230
|
+
operationId: `reveal_${entry.name}`,
|
|
231
|
+
tags: tag,
|
|
232
|
+
summary: `Reveal a sensitive field of a ${entry.name} record`,
|
|
233
|
+
description: "A sensitive field never comes with the record. This returns its value for one record, with the same access as a get, and publishes an audit event first.",
|
|
234
|
+
parameters: [param("key")],
|
|
235
|
+
requestBody: {
|
|
236
|
+
required: true,
|
|
237
|
+
content: json({
|
|
238
|
+
type: "object",
|
|
239
|
+
required: ["field"],
|
|
240
|
+
properties: { field: {
|
|
241
|
+
type: "string",
|
|
242
|
+
enum: sensitive
|
|
243
|
+
} },
|
|
244
|
+
additionalProperties: false
|
|
245
|
+
})
|
|
246
|
+
},
|
|
247
|
+
responses: {
|
|
248
|
+
200: {
|
|
249
|
+
description: "The value; never cached",
|
|
250
|
+
content: json({
|
|
251
|
+
type: "object",
|
|
252
|
+
required: ["field", "value"],
|
|
253
|
+
properties: {
|
|
254
|
+
field: { type: "string" },
|
|
255
|
+
value: {}
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
},
|
|
259
|
+
...keyErrors
|
|
260
|
+
}
|
|
261
|
+
} } },
|
|
262
|
+
[`/${entry.name}:facets`]: { get: {
|
|
263
|
+
operationId: `facets_${entry.name}`,
|
|
264
|
+
tags: tag,
|
|
265
|
+
summary: `Value counts of a ${entry.name} field`,
|
|
266
|
+
description: "Applies the filter except its own terms on the field.",
|
|
267
|
+
parameters: [
|
|
268
|
+
field("A filterable field"),
|
|
269
|
+
param("filter"),
|
|
270
|
+
query("limit", "Most values to return", { type: "integer" })
|
|
271
|
+
],
|
|
272
|
+
responses: {
|
|
273
|
+
200: {
|
|
274
|
+
description: "Counts, most frequent first",
|
|
275
|
+
content: json({
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
field: { type: "string" },
|
|
279
|
+
facets: {
|
|
280
|
+
type: "array",
|
|
281
|
+
items: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
value: {},
|
|
285
|
+
count: { type: "integer" }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
},
|
|
292
|
+
400: error("BadRequest"),
|
|
293
|
+
...auth
|
|
294
|
+
}
|
|
295
|
+
} },
|
|
296
|
+
[`/${entry.name}:series`]: { get: {
|
|
297
|
+
operationId: `series_${entry.name}`,
|
|
298
|
+
tags: tag,
|
|
299
|
+
summary: `Counts per time bucket of a ${entry.name} date field`,
|
|
300
|
+
parameters: [
|
|
301
|
+
field("A filterable date or timestamp field"),
|
|
302
|
+
param("filter"),
|
|
303
|
+
query("range", "Relative range ending now (default 30d); `all` starts at the earliest row", {
|
|
304
|
+
type: "string",
|
|
305
|
+
enum: [
|
|
306
|
+
"7d",
|
|
307
|
+
"30d",
|
|
308
|
+
"90d",
|
|
309
|
+
"1y",
|
|
310
|
+
"all"
|
|
311
|
+
]
|
|
312
|
+
}),
|
|
313
|
+
query("from", "Explicit start (ISO 8601); with `to`, replaces range"),
|
|
314
|
+
query("to", "Exclusive end (ISO 8601), default now"),
|
|
315
|
+
query("granularity", "Default day", {
|
|
316
|
+
type: "string",
|
|
317
|
+
enum: [
|
|
318
|
+
"hour",
|
|
319
|
+
"day",
|
|
320
|
+
"week",
|
|
321
|
+
"month"
|
|
322
|
+
]
|
|
323
|
+
}),
|
|
324
|
+
query("time_zone", "IANA time zone for the buckets, default UTC")
|
|
325
|
+
],
|
|
326
|
+
responses: {
|
|
327
|
+
200: {
|
|
328
|
+
description: "Buckets in local ISO time, empty ones included",
|
|
329
|
+
content: json({
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
field: { type: "string" },
|
|
333
|
+
granularity: { type: "string" },
|
|
334
|
+
time_zone: { type: "string" },
|
|
335
|
+
points: {
|
|
336
|
+
type: "array",
|
|
337
|
+
items: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
bucket: { type: "string" },
|
|
341
|
+
count: { type: "integer" }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
},
|
|
348
|
+
400: error("BadRequest"),
|
|
349
|
+
...auth
|
|
350
|
+
}
|
|
351
|
+
} },
|
|
352
|
+
[`/${entry.name}:histogram`]: { get: {
|
|
353
|
+
operationId: `histogram_${entry.name}`,
|
|
354
|
+
tags: tag,
|
|
355
|
+
summary: `Equal-width buckets of a numeric ${entry.name} field`,
|
|
356
|
+
parameters: [
|
|
357
|
+
field("A filterable numeric field"),
|
|
358
|
+
param("filter"),
|
|
359
|
+
query("buckets", "Default 20, at most 200", { type: "integer" })
|
|
360
|
+
],
|
|
361
|
+
responses: {
|
|
362
|
+
200: {
|
|
363
|
+
description: "Buckets between the filtered minimum and maximum",
|
|
364
|
+
content: json({
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
field: { type: "string" },
|
|
368
|
+
min: { type: ["number", "null"] },
|
|
369
|
+
max: { type: ["number", "null"] },
|
|
370
|
+
buckets: {
|
|
371
|
+
type: "array",
|
|
372
|
+
items: {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: {
|
|
375
|
+
from: { type: "number" },
|
|
376
|
+
to: { type: "number" },
|
|
377
|
+
count: { type: "integer" }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
400: error("BadRequest"),
|
|
385
|
+
...auth
|
|
386
|
+
}
|
|
387
|
+
} },
|
|
388
|
+
[`/${entry.name}:seek`]: { get: {
|
|
389
|
+
operationId: `seek_${entry.name}`,
|
|
390
|
+
tags: tag,
|
|
391
|
+
summary: `A page of ${entry.name} at a row position`,
|
|
392
|
+
description: "For scrollbar jumps: resolves a 0-based row position, within the rows the caller can see and in the given order, to a page of rows, with no OFFSET. The estimate behind it is scaled to the caller's own rows; no column statistics or boundary values are returned, only the page and its tokens.",
|
|
393
|
+
parameters: [
|
|
394
|
+
query("position", "0-based row position to jump to", {
|
|
395
|
+
type: "integer",
|
|
396
|
+
minimum: 0
|
|
397
|
+
}, true),
|
|
398
|
+
param("orderBy"),
|
|
399
|
+
param("filter"),
|
|
400
|
+
param("pageSize"),
|
|
401
|
+
param("fields"),
|
|
402
|
+
param("count")
|
|
403
|
+
],
|
|
404
|
+
responses: {
|
|
405
|
+
200: {
|
|
406
|
+
description: "The page at that position; continue from either token",
|
|
407
|
+
content: page(name, true)
|
|
408
|
+
},
|
|
409
|
+
400: error("BadRequest"),
|
|
410
|
+
...auth
|
|
411
|
+
}
|
|
412
|
+
} }
|
|
413
|
+
};
|
|
414
|
+
return withoutDenied(paths, access);
|
|
415
|
+
};
|
|
416
|
+
//#endregion
|
|
417
|
+
export { resourcePaths };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { writableFields } from "../request-access.js";
|
|
2
|
+
import { pascal } from "./naming.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/openapi/schemas.ts
|
|
5
|
+
const convert = (entry, io) => {
|
|
6
|
+
const schema = z.toJSONSchema(entry.source.recordSchema(), {
|
|
7
|
+
io,
|
|
8
|
+
unrepresentable: "any",
|
|
9
|
+
override: ({ jsonSchema }) => {
|
|
10
|
+
if (jsonSchema.format) delete jsonSchema.pattern;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
delete schema.$schema;
|
|
14
|
+
return schema;
|
|
15
|
+
};
|
|
16
|
+
const hoist = (entry, schema) => {
|
|
17
|
+
const { $defs = {}, ...rest } = schema;
|
|
18
|
+
const rename = (key) => `${pascal(entry.name)}${key.replace(/^__/, "_")}`;
|
|
19
|
+
let text = JSON.stringify(rest);
|
|
20
|
+
for (const key of Object.keys($defs)) text = text.replaceAll(`"#/$defs/${key}"`, `"#/components/schemas/${rename(key)}"`);
|
|
21
|
+
const hoisted = Object.fromEntries(Object.entries($defs).map(([key, value]) => [rename(key), JSON.parse(JSON.stringify(value).replaceAll(`"#/$defs/${key}"`, `"#/components/schemas/${rename(key)}"`))]));
|
|
22
|
+
return {
|
|
23
|
+
schema: JSON.parse(text),
|
|
24
|
+
hoisted
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const only = (schema, names) => ({
|
|
28
|
+
...schema,
|
|
29
|
+
properties: Object.fromEntries(names.filter((name) => schema.properties?.[name]).map((name) => [name, schema.properties[name]])),
|
|
30
|
+
...schema.required && { required: schema.required.filter((name) => names.includes(name)) }
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* Component schemas of a resource for one caller: the record with the fields they may read, and the create and update
|
|
34
|
+
* bodies with the fields they may write. Hidden fields appear nowhere.
|
|
35
|
+
*/
|
|
36
|
+
const resourceSchemas = (access) => {
|
|
37
|
+
const { entry, model, resolved } = access;
|
|
38
|
+
const name = pascal(entry.name);
|
|
39
|
+
const readable = Object.keys(model.fields);
|
|
40
|
+
const output = hoist(entry, convert(entry, "output"));
|
|
41
|
+
const input = hoist(entry, convert(entry, "input"));
|
|
42
|
+
const record = only(output.schema, readable);
|
|
43
|
+
for (const field of Object.values(model.fields)) if (field.readOnly && record.properties?.[field.name]) record.properties[field.name].readOnly = true;
|
|
44
|
+
record.properties.etag = {
|
|
45
|
+
type: "string",
|
|
46
|
+
readOnly: true,
|
|
47
|
+
description: "The record version, as in the ETag header; send it in If-Match when updating or deleting"
|
|
48
|
+
};
|
|
49
|
+
record.properties.permissions = {
|
|
50
|
+
type: "object",
|
|
51
|
+
readOnly: true,
|
|
52
|
+
description: "What the caller may do with this record: update and delete, and on a get also `fields`, the mode (`edit` or `read`) of each field",
|
|
53
|
+
properties: {
|
|
54
|
+
update: { type: "boolean" },
|
|
55
|
+
delete: { type: "boolean" },
|
|
56
|
+
fields: {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: {
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: ["edit", "read"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
record.required = [...record.required ?? [], "etag"];
|
|
66
|
+
const writable = access.readable;
|
|
67
|
+
const body = (names) => {
|
|
68
|
+
const properties = only(input.schema, names).properties;
|
|
69
|
+
const optional = new Set(names.filter((field) => writable.fields[field]?.nullable || writable.fields[field]?.default && "db" in writable.fields[field].default));
|
|
70
|
+
for (const field of names) {
|
|
71
|
+
const fieldModel = writable.fields[field];
|
|
72
|
+
if (fieldModel?.default && "db" in fieldModel.default && properties[field]) properties[field].description = "Defaults to a value the database generates";
|
|
73
|
+
}
|
|
74
|
+
const required = input.schema.required?.filter((field) => names.includes(field) && !optional.has(field)) ?? [];
|
|
75
|
+
return {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties,
|
|
78
|
+
...required.length > 0 && { required },
|
|
79
|
+
additionalProperties: false
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const create = body(writableFields(access, "create"));
|
|
83
|
+
const update = { ...body(writableFields(access, "update")) };
|
|
84
|
+
delete update.required;
|
|
85
|
+
return {
|
|
86
|
+
[name]: record,
|
|
87
|
+
...resolved.operations.create && { [`${name}Create`]: create },
|
|
88
|
+
...resolved.operations.update && { [`${name}Update`]: update },
|
|
89
|
+
...output.hoisted
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
//#endregion
|
|
93
|
+
export { resourceSchemas };
|
package/dist/params.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { badRequest } from "./problem.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/params.ts
|
|
4
|
+
/** Parses query or body parameters; a failure is a 400 problem listing each offending parameter. */
|
|
5
|
+
const parseParams = (schema, input) => {
|
|
6
|
+
const parsed = schema.safeParse(input);
|
|
7
|
+
if (parsed.success) return parsed.data;
|
|
8
|
+
const errors = parsed.error.issues.map((issue) => ({
|
|
9
|
+
parameter: issue.path.join("."),
|
|
10
|
+
message: issue.message
|
|
11
|
+
}));
|
|
12
|
+
throw badRequest("invalid-parameter", `Invalid parameter ${errors[0].parameter}: ${errors[0].message}`, { errors });
|
|
13
|
+
};
|
|
14
|
+
const listParams = z.object({
|
|
15
|
+
filter: z.string().optional(),
|
|
16
|
+
order_by: z.string().optional(),
|
|
17
|
+
page_size: z.coerce.number().int().min(0).optional(),
|
|
18
|
+
page_token: z.string().optional(),
|
|
19
|
+
fields: z.union([z.string(), z.array(z.string())]).optional(),
|
|
20
|
+
count: z.literal("exact").optional(),
|
|
21
|
+
show_deleted: z.union([z.boolean(), z.enum(["true", "false"]).transform((value) => value === "true")]).optional()
|
|
22
|
+
});
|
|
23
|
+
/** The list parameters plus the row position to jump to (`:seek`). */
|
|
24
|
+
const seekParams = listParams.extend({ position: z.coerce.number().int().min(0) });
|
|
25
|
+
/** AIP-158: 0 or absent means the default, anything above the maximum is coerced down. */
|
|
26
|
+
const pageSizeOf = (requested) => Math.min(requested || 50, 500);
|
|
27
|
+
/** AIP-157 style column selection: comma separated field names, or an array in a body. */
|
|
28
|
+
const selectedFields = (model, fields) => {
|
|
29
|
+
if (fields === void 0) return void 0;
|
|
30
|
+
const names = (Array.isArray(fields) ? fields : fields.split(",")).map((name) => name.trim()).filter(Boolean);
|
|
31
|
+
const unknown = names.filter((name) => !Object.hasOwn(model.fields, name));
|
|
32
|
+
if (unknown.length > 0) throw badRequest("invalid-parameter", `Unknown fields: ${unknown.join(", ")}`, { errors: unknown.map((name) => ({
|
|
33
|
+
parameter: "fields",
|
|
34
|
+
message: `Unknown field "${name}"`
|
|
35
|
+
})) });
|
|
36
|
+
return names.length > 0 ? names : void 0;
|
|
37
|
+
};
|
|
38
|
+
/** AIP-164 `show_deleted`: only soft-delete resources have deleted rows to show. */
|
|
39
|
+
const deletedRows = (model, showDeleted) => {
|
|
40
|
+
if (!showDeleted) return "hide";
|
|
41
|
+
if (!model.softDelete) throw badRequest("invalid-parameter", `Resource "${model.name}" does not soft delete, so show_deleted does not apply`, { errors: [{
|
|
42
|
+
parameter: "show_deleted",
|
|
43
|
+
message: "Not supported"
|
|
44
|
+
}] });
|
|
45
|
+
return "show";
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/problem.d.ts
|
|
2
|
+
export type ProblemExtras = Record<string, unknown>;
|
|
3
|
+
/** An RFC 9457 problem. Throw it anywhere below a route; the central error handler renders it. */
|
|
4
|
+
export declare class HttpProblem extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly slug: string;
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly extras: ProblemExtras;
|
|
9
|
+
readonly headers: Record<string, string>;
|
|
10
|
+
constructor(status: number, slug: string, title: string, detail: string, extras?: ProblemExtras, headers?: Record<string, string>);
|
|
11
|
+
get detail(): string;
|
|
12
|
+
}
|
|
13
|
+
export declare const badRequest: (slug: string, detail: string, extras?: ProblemExtras) => HttpProblem;
|
|
14
|
+
export declare const unauthorized: (detail: string) => HttpProblem;
|
|
15
|
+
export declare const forbidden: (slug: string, detail: string) => HttpProblem;
|
|
16
|
+
export declare const notFound: (detail: string) => HttpProblem;
|
|
17
|
+
//#endregion
|