@proseql/rest 0.2.2 → 0.2.4
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 +419 -0
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# @proseql/rest
|
|
2
|
+
|
|
3
|
+
Framework-agnostic REST API handlers for ProseQL databases. Generate HTTP endpoints for CRUD, queries, and aggregations from your database config.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @proseql/rest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Effect, Schema } from "effect"
|
|
15
|
+
import { createEffectDatabase } from "@proseql/core"
|
|
16
|
+
import { createRestHandlers } from "@proseql/rest"
|
|
17
|
+
|
|
18
|
+
const BookSchema = Schema.Struct({
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
title: Schema.String,
|
|
21
|
+
author: Schema.String,
|
|
22
|
+
year: Schema.Number,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const config = {
|
|
26
|
+
books: {
|
|
27
|
+
schema: BookSchema,
|
|
28
|
+
relationships: {},
|
|
29
|
+
},
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const program = Effect.gen(function* () {
|
|
33
|
+
const db = yield* createEffectDatabase(config, {
|
|
34
|
+
books: [{ id: "1", title: "Dune", author: "Frank Herbert", year: 1965 }],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const routes = createRestHandlers(config, db)
|
|
38
|
+
|
|
39
|
+
// routes is an array of { method, path, handler }
|
|
40
|
+
// Adapt to your framework of choice
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await Effect.runPromise(program)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For the full query and mutation API, see [`@proseql/core`](https://www.npmjs.com/package/@proseql/core).
|
|
47
|
+
|
|
48
|
+
## Handler Generation
|
|
49
|
+
|
|
50
|
+
`createRestHandlers` generates framework-agnostic route descriptors for all collections in your database config.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createRestHandlers } from "@proseql/rest"
|
|
54
|
+
|
|
55
|
+
const routes = createRestHandlers(config, db)
|
|
56
|
+
// routes: ReadonlyArray<RouteDescriptor>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each route descriptor contains:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
interface RouteDescriptor {
|
|
63
|
+
readonly method: "GET" | "POST" | "PUT" | "DELETE"
|
|
64
|
+
readonly path: string
|
|
65
|
+
readonly handler: RestHandler
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Generated Routes
|
|
70
|
+
|
|
71
|
+
For each collection, the following routes are generated:
|
|
72
|
+
|
|
73
|
+
| Method | Path | Description |
|
|
74
|
+
|--------|------|-------------|
|
|
75
|
+
| `GET` | `/:collection` | Query with filters, sort, pagination |
|
|
76
|
+
| `GET` | `/:collection/:id` | Find entity by ID |
|
|
77
|
+
| `POST` | `/:collection` | Create entity |
|
|
78
|
+
| `PUT` | `/:collection/:id` | Update entity |
|
|
79
|
+
| `DELETE` | `/:collection/:id` | Delete entity |
|
|
80
|
+
| `POST` | `/:collection/batch` | Create multiple entities |
|
|
81
|
+
| `GET` | `/:collection/aggregate` | Aggregation queries |
|
|
82
|
+
|
|
83
|
+
For a database with `books` and `authors` collections, this generates:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
GET /books
|
|
87
|
+
GET /books/:id
|
|
88
|
+
POST /books
|
|
89
|
+
PUT /books/:id
|
|
90
|
+
DELETE /books/:id
|
|
91
|
+
POST /books/batch
|
|
92
|
+
GET /books/aggregate
|
|
93
|
+
|
|
94
|
+
GET /authors
|
|
95
|
+
GET /authors/:id
|
|
96
|
+
POST /authors
|
|
97
|
+
PUT /authors/:id
|
|
98
|
+
DELETE /authors/:id
|
|
99
|
+
POST /authors/batch
|
|
100
|
+
GET /authors/aggregate
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Request and Response Types
|
|
104
|
+
|
|
105
|
+
Handlers use framework-agnostic request/response types.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface RestRequest {
|
|
109
|
+
readonly params: Record<string, string> // URL path params
|
|
110
|
+
readonly query: Record<string, string | string[]> // Query string params
|
|
111
|
+
readonly body: unknown // Parsed JSON body
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface RestResponse {
|
|
115
|
+
readonly status: number
|
|
116
|
+
readonly body: unknown
|
|
117
|
+
readonly headers?: Record<string, string>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type RestHandler = (req: RestRequest) => Promise<RestResponse>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Query Parameter Parsing
|
|
124
|
+
|
|
125
|
+
Use `parseQueryParams` to convert URL query strings into ProseQL query configurations.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { parseQueryParams } from "@proseql/rest"
|
|
129
|
+
|
|
130
|
+
// Simple equality
|
|
131
|
+
parseQueryParams({ genre: "sci-fi" })
|
|
132
|
+
// → { where: { genre: "sci-fi" } }
|
|
133
|
+
|
|
134
|
+
// Operator syntax
|
|
135
|
+
parseQueryParams({ "year[$gte]": "1970", "year[$lt]": "2000" })
|
|
136
|
+
// → { where: { year: { $gte: 1970, $lt: 2000 } } }
|
|
137
|
+
|
|
138
|
+
// Sorting
|
|
139
|
+
parseQueryParams({ sort: "year:desc,title:asc" })
|
|
140
|
+
// → { sort: { year: "desc", title: "asc" } }
|
|
141
|
+
|
|
142
|
+
// Pagination
|
|
143
|
+
parseQueryParams({ limit: "10", offset: "20" })
|
|
144
|
+
// → { limit: 10, offset: 20 }
|
|
145
|
+
|
|
146
|
+
// Field selection
|
|
147
|
+
parseQueryParams({ select: "title,year" })
|
|
148
|
+
// → { select: ["title", "year"] }
|
|
149
|
+
|
|
150
|
+
// Combined
|
|
151
|
+
parseQueryParams({
|
|
152
|
+
genre: "sci-fi",
|
|
153
|
+
"year[$gte]": "1970",
|
|
154
|
+
sort: "year:desc",
|
|
155
|
+
limit: "10",
|
|
156
|
+
select: "title,year"
|
|
157
|
+
})
|
|
158
|
+
// → {
|
|
159
|
+
// where: { genre: "sci-fi", year: { $gte: 1970 } },
|
|
160
|
+
// sort: { year: "desc" },
|
|
161
|
+
// limit: 10,
|
|
162
|
+
// select: ["title", "year"]
|
|
163
|
+
// }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Supported Filter Operators
|
|
167
|
+
|
|
168
|
+
| URL Syntax | ProseQL Operator |
|
|
169
|
+
|------------|------------------|
|
|
170
|
+
| `field=value` | `{ field: value }` (equality) |
|
|
171
|
+
| `field[$eq]=value` | `{ field: { $eq: value } }` |
|
|
172
|
+
| `field[$ne]=value` | `{ field: { $ne: value } }` |
|
|
173
|
+
| `field[$gt]=value` | `{ field: { $gt: value } }` |
|
|
174
|
+
| `field[$gte]=value` | `{ field: { $gte: value } }` |
|
|
175
|
+
| `field[$lt]=value` | `{ field: { $lt: value } }` |
|
|
176
|
+
| `field[$lte]=value` | `{ field: { $lte: value } }` |
|
|
177
|
+
| `field[$in]=a,b,c` | `{ field: { $in: ["a", "b", "c"] } }` |
|
|
178
|
+
| `field[$nin]=a,b` | `{ field: { $nin: ["a", "b"] } }` |
|
|
179
|
+
| `field[$startsWith]=val` | `{ field: { $startsWith: "val" } }` |
|
|
180
|
+
| `field[$endsWith]=val` | `{ field: { $endsWith: "val" } }` |
|
|
181
|
+
| `field[$contains]=val` | `{ field: { $contains: "val" } }` |
|
|
182
|
+
| `field[$search]=term` | `{ field: { $search: "term" } }` |
|
|
183
|
+
| `field[$all]=a,b` | `{ field: { $all: ["a", "b"] } }` |
|
|
184
|
+
| `field[$size]=3` | `{ field: { $size: 3 } }` |
|
|
185
|
+
|
|
186
|
+
Type coercion is applied automatically:
|
|
187
|
+
- Numeric strings become numbers for numeric operators
|
|
188
|
+
- `"true"` and `"false"` become booleans
|
|
189
|
+
- Comma-separated values in `$in`/`$nin`/`$all` become arrays
|
|
190
|
+
|
|
191
|
+
## Aggregate Parameter Parsing
|
|
192
|
+
|
|
193
|
+
Use `parseAggregateParams` for aggregation endpoint query strings.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { parseAggregateParams } from "@proseql/rest"
|
|
197
|
+
|
|
198
|
+
// Simple count
|
|
199
|
+
parseAggregateParams({ count: "true" })
|
|
200
|
+
// → { count: true }
|
|
201
|
+
|
|
202
|
+
// Count with filter
|
|
203
|
+
parseAggregateParams({ count: "true", genre: "sci-fi" })
|
|
204
|
+
// → { count: true, where: { genre: "sci-fi" } }
|
|
205
|
+
|
|
206
|
+
// Grouped aggregate
|
|
207
|
+
parseAggregateParams({ count: "true", groupBy: "genre" })
|
|
208
|
+
// → { count: true, groupBy: "genre" }
|
|
209
|
+
|
|
210
|
+
// Multiple aggregations
|
|
211
|
+
parseAggregateParams({
|
|
212
|
+
count: "true",
|
|
213
|
+
sum: "pages",
|
|
214
|
+
avg: "rating",
|
|
215
|
+
groupBy: "genre"
|
|
216
|
+
})
|
|
217
|
+
// → { count: true, sum: "pages", avg: "rating", groupBy: "genre" }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Aggregate Query Parameters
|
|
221
|
+
|
|
222
|
+
| Parameter | Description |
|
|
223
|
+
|-----------|-------------|
|
|
224
|
+
| `count=true` | Count entities |
|
|
225
|
+
| `sum=field` | Sum a numeric field |
|
|
226
|
+
| `avg=field` | Average a numeric field |
|
|
227
|
+
| `min=field` | Find minimum value |
|
|
228
|
+
| `max=field` | Find maximum value |
|
|
229
|
+
| `groupBy=field` | Group results by field |
|
|
230
|
+
| `sum=a,b` | Sum multiple fields |
|
|
231
|
+
| `groupBy=a,b` | Group by multiple fields |
|
|
232
|
+
|
|
233
|
+
Filter parameters (same syntax as query endpoint) are also supported for filtered aggregation.
|
|
234
|
+
|
|
235
|
+
## Error Mapping
|
|
236
|
+
|
|
237
|
+
Use `mapErrorToResponse` to convert ProseQL errors to HTTP responses.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { mapErrorToResponse } from "@proseql/rest"
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await db.books.findById("nonexistent").runPromise
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const response = mapErrorToResponse(error)
|
|
246
|
+
// response = {
|
|
247
|
+
// status: 404,
|
|
248
|
+
// body: {
|
|
249
|
+
// _tag: "NotFoundError",
|
|
250
|
+
// error: "Not found",
|
|
251
|
+
// details: { collection: "books", id: "nonexistent" }
|
|
252
|
+
// }
|
|
253
|
+
// }
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Error Status Codes
|
|
258
|
+
|
|
259
|
+
| Error | Status | Description |
|
|
260
|
+
|-------|--------|-------------|
|
|
261
|
+
| `NotFoundError` | 404 | Entity doesn't exist |
|
|
262
|
+
| `ValidationError` | 400 | Invalid input data |
|
|
263
|
+
| `DuplicateKeyError` | 409 | ID already taken |
|
|
264
|
+
| `UniqueConstraintError` | 409 | Unique field collision |
|
|
265
|
+
| `ForeignKeyError` | 422 | Referenced entity doesn't exist |
|
|
266
|
+
| `HookError` | 422 | Lifecycle hook rejected |
|
|
267
|
+
| `OperationError` | 400 | Invalid operation (e.g., update on append-only) |
|
|
268
|
+
| `ConcurrencyError` | 409 | Concurrent modification conflict |
|
|
269
|
+
| `CollectionNotFoundError` | 404 | Collection doesn't exist |
|
|
270
|
+
| `PopulationError` | 422 | Relationship population failed |
|
|
271
|
+
| `DanglingReferenceError` | 422 | Dangling reference |
|
|
272
|
+
| `StorageError` | 500 | Storage adapter error |
|
|
273
|
+
| `SerializationError` | 500 | Codec error |
|
|
274
|
+
| `UnsupportedFormatError` | 400 | Unsupported file format |
|
|
275
|
+
| `TransactionError` | 500 | Transaction error |
|
|
276
|
+
| `MigrationError` | 500 | Migration error |
|
|
277
|
+
| `PluginError` | 500 | Plugin error |
|
|
278
|
+
|
|
279
|
+
## Relationship Routes
|
|
280
|
+
|
|
281
|
+
Use `createRelationshipRoutes` to generate sub-routes for relationship navigation.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { createRelationshipRoutes } from "@proseql/rest"
|
|
285
|
+
|
|
286
|
+
const config = {
|
|
287
|
+
books: {
|
|
288
|
+
schema: BookSchema,
|
|
289
|
+
relationships: {
|
|
290
|
+
author: { type: "ref" as const, target: "authors" as const, foreignKey: "authorId" },
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
authors: {
|
|
294
|
+
schema: AuthorSchema,
|
|
295
|
+
relationships: {
|
|
296
|
+
books: { type: "inverse" as const, target: "books" as const, foreignKey: "authorId" },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
} as const
|
|
300
|
+
|
|
301
|
+
const relationshipRoutes = createRelationshipRoutes(config, db)
|
|
302
|
+
// Generates:
|
|
303
|
+
// GET /books/:id/author — returns the author of a book
|
|
304
|
+
// GET /authors/:id/books — returns all books by an author
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Route Patterns
|
|
308
|
+
|
|
309
|
+
| Relationship Type | Pattern | Behavior |
|
|
310
|
+
|-------------------|---------|----------|
|
|
311
|
+
| `ref` | `GET /:collection/:id/:relationship` | Returns single related entity |
|
|
312
|
+
| `inverse` | `GET /:collection/:id/:relationship` | Returns array of related entities |
|
|
313
|
+
|
|
314
|
+
Use `extractRelationships` to inspect relationships in your config:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { extractRelationships } from "@proseql/rest"
|
|
318
|
+
|
|
319
|
+
const relationships = extractRelationships(config)
|
|
320
|
+
// [
|
|
321
|
+
// { sourceCollection: "books", relationshipName: "author", relationship: { type: "ref", ... } },
|
|
322
|
+
// { sourceCollection: "authors", relationshipName: "books", relationship: { type: "inverse", ... } }
|
|
323
|
+
// ]
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Framework Integration
|
|
327
|
+
|
|
328
|
+
### Express
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import express from "express"
|
|
332
|
+
import { createRestHandlers, createRelationshipRoutes } from "@proseql/rest"
|
|
333
|
+
|
|
334
|
+
const app = express()
|
|
335
|
+
app.use(express.json())
|
|
336
|
+
|
|
337
|
+
const routes = [
|
|
338
|
+
...createRestHandlers(config, db),
|
|
339
|
+
...createRelationshipRoutes(config, db),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
for (const { method, path, handler } of routes) {
|
|
343
|
+
app[method.toLowerCase() as "get" | "post" | "put" | "delete"](
|
|
344
|
+
path,
|
|
345
|
+
async (req, res) => {
|
|
346
|
+
const response = await handler({
|
|
347
|
+
params: req.params,
|
|
348
|
+
query: req.query as Record<string, string | string[]>,
|
|
349
|
+
body: req.body,
|
|
350
|
+
})
|
|
351
|
+
res.status(response.status).json(response.body)
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
app.listen(3000)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Hono
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import { Hono } from "hono"
|
|
363
|
+
import { createRestHandlers, createRelationshipRoutes } from "@proseql/rest"
|
|
364
|
+
|
|
365
|
+
const app = new Hono()
|
|
366
|
+
|
|
367
|
+
const routes = [
|
|
368
|
+
...createRestHandlers(config, db),
|
|
369
|
+
...createRelationshipRoutes(config, db),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
for (const { method, path, handler } of routes) {
|
|
373
|
+
// Convert :param to Hono's :param syntax (same format)
|
|
374
|
+
app[method.toLowerCase() as "get" | "post" | "put" | "delete"](
|
|
375
|
+
path,
|
|
376
|
+
async (c) => {
|
|
377
|
+
const response = await handler({
|
|
378
|
+
params: c.req.param(),
|
|
379
|
+
query: c.req.query() as Record<string, string | string[]>,
|
|
380
|
+
body: method === "GET" ? undefined : await c.req.json(),
|
|
381
|
+
})
|
|
382
|
+
return c.json(response.body, response.status as 200)
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default app
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## API Reference
|
|
391
|
+
|
|
392
|
+
### Exports
|
|
393
|
+
|
|
394
|
+
| Export | Description |
|
|
395
|
+
|--------|-------------|
|
|
396
|
+
| `createRestHandlers` | Generate REST handlers for all collections |
|
|
397
|
+
| `createRelationshipRoutes` | Generate relationship sub-routes |
|
|
398
|
+
| `extractRelationships` | Inspect relationships in config |
|
|
399
|
+
| `parseQueryParams` | Parse URL query params for queries |
|
|
400
|
+
| `parseAggregateParams` | Parse URL query params for aggregation |
|
|
401
|
+
| `mapErrorToResponse` | Map ProseQL errors to HTTP responses |
|
|
402
|
+
|
|
403
|
+
### Types
|
|
404
|
+
|
|
405
|
+
| Type | Description |
|
|
406
|
+
|------|-------------|
|
|
407
|
+
| `RestHandler` | Handler function signature |
|
|
408
|
+
| `RestRequest` | Framework-agnostic request |
|
|
409
|
+
| `RestResponse` | Framework-agnostic response |
|
|
410
|
+
| `RouteDescriptor` | Route definition (method, path, handler) |
|
|
411
|
+
| `HttpMethod` | `"GET" \| "POST" \| "PUT" \| "DELETE" \| "PATCH"` |
|
|
412
|
+
| `ParsedQueryConfig` | Parsed query configuration |
|
|
413
|
+
| `ParsedAggregateConfig` | Parsed aggregate configuration |
|
|
414
|
+
| `QueryParams` | Input query parameter map |
|
|
415
|
+
| `ErrorResponse` | Error response structure |
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proseql/rest",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "REST API handlers for ProseQL databases, framework-agnostic",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"sideEffects": false,
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proseql/core": "^0.2.
|
|
44
|
+
"@proseql/core": "^0.2.4"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"effect": "^3.15.0"
|