@thomasfosterau/effect-sparql-node 0.0.0 → 0.1.0

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -1
  3. package/bin/effect-sparql-server.mjs +26 -0
  4. package/dist/Cli-DE0eXMJr.d.mts +143 -0
  5. package/dist/Cli-DFvng1N8.mjs +259 -0
  6. package/dist/Cli-DFvng1N8.mjs.map +1 -0
  7. package/dist/Cli.d.mts +2 -0
  8. package/dist/Cli.mjs +1 -0
  9. package/dist/NodeSparqlServer-1vSrPk3J.d.mts +41 -0
  10. package/dist/NodeSparqlServer-CYAd_6BG.mjs +50 -0
  11. package/dist/NodeSparqlServer-CYAd_6BG.mjs.map +1 -0
  12. package/dist/NodeSparqlServer.d.mts +2 -0
  13. package/dist/NodeSparqlServer.mjs +1 -0
  14. package/dist/SparqlApi-BKE9nIf4.d.mts +488 -0
  15. package/dist/SparqlApi-BuycDkWu.mjs +371 -0
  16. package/dist/SparqlApi-BuycDkWu.mjs.map +1 -0
  17. package/dist/SparqlApi.d.mts +2 -0
  18. package/dist/SparqlApi.mjs +1 -0
  19. package/dist/SparqlApiBuilder-BAMpMy-Z.d.mts +83 -0
  20. package/dist/SparqlApiBuilder-GT7EopBq.mjs +136 -0
  21. package/dist/SparqlApiBuilder-GT7EopBq.mjs.map +1 -0
  22. package/dist/SparqlApiBuilder.d.mts +2 -0
  23. package/dist/SparqlApiBuilder.mjs +1 -0
  24. package/dist/SparqlApiClient-C82RGMEF.d.mts +386 -0
  25. package/dist/SparqlApiClient-CJcoFy5b.mjs +203 -0
  26. package/dist/SparqlApiClient-CJcoFy5b.mjs.map +1 -0
  27. package/dist/SparqlApiClient.d.mts +2 -0
  28. package/dist/SparqlApiClient.mjs +1 -0
  29. package/dist/index.d.mts +6 -0
  30. package/dist/index.mjs +6 -0
  31. package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
  32. package/package.json +59 -3
  33. package/src/Cli.ts +373 -0
  34. package/src/NodeSparqlServer.ts +71 -0
  35. package/src/SparqlApi.ts +506 -0
  36. package/src/SparqlApiBuilder.ts +196 -0
  37. package/src/SparqlApiClient.ts +383 -0
  38. package/src/index.ts +20 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * The SPARQL 1.1 Protocol and Graph Store HTTP Protocol as an Effect
3
+ * `HttpApi` definition: the endpoints, their parameters, request media types,
4
+ * response media types and error statuses, shared by the server
5
+ * (`SparqlApiBuilder`), the derived client
6
+ * (`SparqlApiClient`) and the OpenAPI document
7
+ * (`openApi`).
8
+ *
9
+ * The API has two groups:
10
+ *
11
+ * - `sparql`: `query` (`GET` with `?query=`, or without it for the service
12
+ * description) and `post` (`POST` with an
13
+ * `application/x-www-form-urlencoded`, `application/sparql-query` or
14
+ * `application/sparql-update` body);
15
+ * - `graphStore`: `get`, `head`, `put`, `post` and `delete` on the graph store
16
+ * with `?graph=<iri>` or `?default`, with RDF bodies under every media type
17
+ * of the default `Formats`.
18
+ *
19
+ * Content negotiation (`Accept`) and `405 Method Not Allowed` are not part of
20
+ * the definition: the server's handlers negotiate with the same code as
21
+ * `SparqlServer`, and `SparqlApiBuilder` answers the methods the API does not
22
+ * declare. A request whose parameters the API's schemas reject (a repeated
23
+ * `query` or `graph`, say) is handed to the protocol handler too, by the
24
+ * `SchemaFallback` middleware, so it gets the protocol's own `400` and
25
+ * message. Error bodies are `text/plain` messages decoded into
26
+ * `ProtocolErrors.ProtocolError`.
27
+ *
28
+ * This module only describes the API, so a client can import it without the
29
+ * server. `effect/http-api` is `@stability unstable` in Effect 4, and this
30
+ * definition follows it.
31
+ *
32
+ * @since 0.1.0
33
+ */
34
+ import * as Arr from 'effect/Array';
35
+ import * as Schema from 'effect/Schema';
36
+ import * as SchemaTransformation from 'effect/SchemaTransformation';
37
+ import * as HttpApi from 'effect/http-api/HttpApi';
38
+ import * as HttpApiEndpoint from 'effect/http-api/HttpApiEndpoint';
39
+ import * as HttpApiGroup from 'effect/http-api/HttpApiGroup';
40
+ import * as HttpApiMiddleware from 'effect/http-api/HttpApiMiddleware';
41
+ import * as HttpApiSchema from 'effect/http-api/HttpApiSchema';
42
+ import * as OpenApi from 'effect/http-api/OpenApi';
43
+ import type * as Serialization from '@thomasfosterau/effect-rdf/Serialization';
44
+ import type * as Engine from '@thomasfosterau/effect-rdf/sparql/Engine';
45
+ import * as Results from '@thomasfosterau/effect-rdf/sparql/Results';
46
+ import type * as Store from '@thomasfosterau/effect-rdf/sparql/Store';
47
+ import * as ProtocolErrors from '@thomasfosterau/effect-rdf/sparql/ProtocolErrors';
48
+ import * as ServerFormats from '@thomasfosterau/effect-rdf/sparql/ServerFormats';
49
+ import * as Protocol from '@thomasfosterau/effect-rdf/sparql/Protocol';
50
+
51
+ // -----------------------------------------------------------------------------
52
+ // Media types
53
+ // -----------------------------------------------------------------------------
54
+
55
+ /**
56
+ * The media types of the default RDF formats (`ServerFormats.codecs`), in order of
57
+ * preference: Graph Store bodies and `CONSTRUCT` / `DESCRIBE` / service
58
+ * description responses.
59
+ *
60
+ * @since 0.1.0
61
+ * @category media types
62
+ */
63
+ export const rdfMediaTypes: ReadonlyArray<string> = Arr.dedupe(
64
+ Arr.flatMap(ServerFormats.codecs, (codec) => codec.format.mediaTypes),
65
+ );
66
+
67
+ /**
68
+ * The media types of SPARQL results (`Results.codecs`): JSON, XML, CSV and
69
+ * TSV, in order of preference.
70
+ *
71
+ * @since 0.1.0
72
+ * @category media types
73
+ */
74
+ export const resultsMediaTypes: ReadonlyArray<string> = Arr.dedupe(
75
+ Arr.flatMap(Results.codecs, (codec) => codec.format.mediaTypes),
76
+ );
77
+
78
+ /**
79
+ * Every media type a query response can have: the results formats for
80
+ * `SELECT` / `ASK`, then the RDF formats for `CONSTRUCT` / `DESCRIBE` and the
81
+ * service description.
82
+ *
83
+ * @since 0.1.0
84
+ * @category media types
85
+ */
86
+ export const queryMediaTypes: ReadonlyArray<string> = Arr.dedupe(
87
+ Arr.appendAll(resultsMediaTypes, rdfMediaTypes),
88
+ );
89
+
90
+ // -----------------------------------------------------------------------------
91
+ // Schemas
92
+ // -----------------------------------------------------------------------------
93
+
94
+ /**
95
+ * A query response body: a byte stream under any of `queryMediaTypes`.
96
+ *
97
+ * @since 0.1.0
98
+ * @category schemas
99
+ */
100
+ export const QueryResponse: HttpApiSchema.StreamUint8Array = HttpApiSchema.StreamUint8Array({
101
+ contentType: queryMediaTypes,
102
+ });
103
+
104
+ /**
105
+ * An RDF document response body: a byte stream under any of `rdfMediaTypes`.
106
+ *
107
+ * @since 0.1.0
108
+ * @category schemas
109
+ */
110
+ export const RdfResponse: HttpApiSchema.StreamUint8Array = HttpApiSchema.StreamUint8Array({
111
+ contentType: rdfMediaTypes,
112
+ });
113
+
114
+ const iris = Schema.optionalKey(Schema.Array(Schema.String));
115
+
116
+ /**
117
+ * The `Accept` request header, which selects the response format.
118
+ *
119
+ * @since 0.1.0
120
+ * @category schemas
121
+ */
122
+ export const AcceptHeader = Schema.Struct({ accept: Schema.optionalKey(Schema.String) });
123
+
124
+ /**
125
+ * The decoded type of `AcceptHeader`.
126
+ *
127
+ * @since 0.1.0
128
+ * @category schemas
129
+ */
130
+ export type AcceptHeader = typeof AcceptHeader.Type;
131
+
132
+ /**
133
+ * The query string of a query: the query and its protocol dataset.
134
+ *
135
+ * @since 0.1.0
136
+ * @category schemas
137
+ */
138
+ export const QueryParams = Schema.Struct({
139
+ query: Schema.optionalKey(Schema.String),
140
+ 'default-graph-uri': iris,
141
+ 'named-graph-uri': iris,
142
+ });
143
+
144
+ /**
145
+ * The decoded type of `QueryParams`.
146
+ *
147
+ * @since 0.1.0
148
+ * @category schemas
149
+ */
150
+ export type QueryParams = typeof QueryParams.Type;
151
+
152
+ /**
153
+ * The query string of a `POST`: the protocol dataset of a query
154
+ * (`default-graph-uri` / `named-graph-uri`) or of an update
155
+ * (`using-graph-uri` / `using-named-graph-uri`).
156
+ *
157
+ * @since 0.1.0
158
+ * @category schemas
159
+ */
160
+ export const DatasetParams = Schema.Struct({
161
+ 'default-graph-uri': iris,
162
+ 'named-graph-uri': iris,
163
+ 'using-graph-uri': iris,
164
+ 'using-named-graph-uri': iris,
165
+ });
166
+
167
+ /**
168
+ * The decoded type of `DatasetParams`.
169
+ *
170
+ * @since 0.1.0
171
+ * @category schemas
172
+ */
173
+ export type DatasetParams = typeof DatasetParams.Type;
174
+
175
+ /**
176
+ * An `application/x-www-form-urlencoded` `POST` body holding a `query` or an
177
+ * `update` and its protocol dataset.
178
+ *
179
+ * @since 0.1.0
180
+ * @category schemas
181
+ */
182
+ export const FormBody = Schema.Struct({
183
+ query: Schema.optionalKey(Schema.String),
184
+ update: Schema.optionalKey(Schema.String),
185
+ 'default-graph-uri': iris,
186
+ 'named-graph-uri': iris,
187
+ 'using-graph-uri': iris,
188
+ 'using-named-graph-uri': iris,
189
+ }).pipe(HttpApiSchema.asFormUrlEncoded());
190
+
191
+ /**
192
+ * The decoded type of `FormBody`.
193
+ *
194
+ * @since 0.1.0
195
+ * @category schemas
196
+ */
197
+ export type FormBody = typeof FormBody.Type;
198
+
199
+ /**
200
+ * An `application/sparql-query` `POST` body, decoded as
201
+ * `{ _tag: 'DirectQuery', query }`.
202
+ *
203
+ * @since 0.1.0
204
+ * @category schemas
205
+ */
206
+ export const DirectQueryBody = Schema.String.pipe(
207
+ Schema.decodeTo(
208
+ Schema.TaggedStruct('DirectQuery', { query: Schema.String }),
209
+ SchemaTransformation.transform({
210
+ decode: (query) => ({ _tag: 'DirectQuery' as const, query }),
211
+ encode: (body) => body.query,
212
+ }),
213
+ ),
214
+ HttpApiSchema.asText({ contentType: Protocol.SPARQL_QUERY }),
215
+ );
216
+
217
+ /**
218
+ * The decoded type of `DirectQueryBody`.
219
+ *
220
+ * @since 0.1.0
221
+ * @category schemas
222
+ */
223
+ export type DirectQueryBody = typeof DirectQueryBody.Type;
224
+
225
+ /**
226
+ * An `application/sparql-update` `POST` body, decoded as
227
+ * `{ _tag: 'DirectUpdate', update }`.
228
+ *
229
+ * @since 0.1.0
230
+ * @category schemas
231
+ */
232
+ export const DirectUpdateBody = Schema.String.pipe(
233
+ Schema.decodeTo(
234
+ Schema.TaggedStruct('DirectUpdate', { update: Schema.String }),
235
+ SchemaTransformation.transform({
236
+ decode: (update) => ({ _tag: 'DirectUpdate' as const, update }),
237
+ encode: (body) => body.update,
238
+ }),
239
+ ),
240
+ HttpApiSchema.asText({ contentType: Protocol.SPARQL_UPDATE }),
241
+ );
242
+
243
+ /**
244
+ * The decoded type of `DirectUpdateBody`.
245
+ *
246
+ * @since 0.1.0
247
+ * @category schemas
248
+ */
249
+ export type DirectUpdateBody = typeof DirectUpdateBody.Type;
250
+
251
+ const rdfDocument = (mediaType: string) =>
252
+ Schema.String.pipe(
253
+ Schema.decodeTo(
254
+ Schema.Struct({ mediaType: Schema.Literal(mediaType), document: Schema.String }),
255
+ SchemaTransformation.transform({
256
+ decode: (document) => ({ mediaType, document }),
257
+ encode: (body) => body.document,
258
+ }),
259
+ ),
260
+ HttpApiSchema.asText({ contentType: mediaType }),
261
+ );
262
+
263
+ /**
264
+ * Graph Store request bodies: an RDF document under one of `rdfMediaTypes`,
265
+ * decoded as `{ mediaType, document }` (the media type selects the request's
266
+ * `Content-Type`).
267
+ *
268
+ * @since 0.1.0
269
+ * @category schemas
270
+ */
271
+ export const RdfDocumentBody = Arr.map(rdfMediaTypes, rdfDocument);
272
+
273
+ /**
274
+ * The query string of a Graph Store request: `graph=<iri>` (indirect
275
+ * identification) or `default` (the default graph).
276
+ *
277
+ * @since 0.1.0
278
+ * @category schemas
279
+ */
280
+ export const GraphParams = Schema.Struct({
281
+ graph: Schema.optionalKey(Schema.String),
282
+ default: Schema.optionalKey(Schema.String),
283
+ });
284
+
285
+ /**
286
+ * The decoded type of `GraphParams`.
287
+ *
288
+ * @since 0.1.0
289
+ * @category schemas
290
+ */
291
+ export type GraphParams = typeof GraphParams.Type;
292
+
293
+ /**
294
+ * The error response of a status: a `text/plain` message, decoded as an
295
+ * `ProtocolErrors.ProtocolError` with that status.
296
+ *
297
+ * @since 0.1.0
298
+ * @category schemas
299
+ */
300
+ export const ProtocolErrorResponse = (status: number) =>
301
+ Schema.String.pipe(
302
+ Schema.decodeTo(
303
+ ProtocolErrors.ProtocolError.pipe(
304
+ Schema.check(
305
+ Schema.makeFilter((error: ProtocolErrors.ProtocolError) => error.status === status, {
306
+ identifier: `ProtocolError${status}`,
307
+ title: `ProtocolError (${status})`,
308
+ description: `A protocol error with status ${status}`,
309
+ }),
310
+ ),
311
+ ),
312
+ SchemaTransformation.transform({
313
+ decode: (message) => ({
314
+ _tag: 'ProtocolError' as const,
315
+ status,
316
+ message: message.trimEnd(),
317
+ }),
318
+ encode: (error) => `${error.message}\n`,
319
+ }),
320
+ ),
321
+ HttpApiSchema.asText({ contentType: 'text/plain' }),
322
+ HttpApiSchema.status(status),
323
+ );
324
+
325
+ const BadRequest = ProtocolErrorResponse(400);
326
+ const Forbidden = ProtocolErrorResponse(403);
327
+ const NotFound = ProtocolErrorResponse(404);
328
+ const MethodNotAllowed = ProtocolErrorResponse(405);
329
+ const NotAcceptable = ProtocolErrorResponse(406);
330
+ const UnsupportedMediaType = ProtocolErrorResponse(415);
331
+ const InternalServerError = ProtocolErrorResponse(500);
332
+ const ServiceUnavailable = ProtocolErrorResponse(503);
333
+
334
+ // -----------------------------------------------------------------------------
335
+ // The API
336
+ // -----------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Middleware on every endpoint that answers a request whose parameters or
340
+ * headers fail the API's schemas with the protocol handler instead of an
341
+ * empty `400` (see `SparqlApiBuilder.layerSchemaFallback`). It needs the
342
+ * server's services and nothing on the client side.
343
+ *
344
+ * @since 0.1.0
345
+ * @category middleware
346
+ */
347
+ export class SchemaFallback extends HttpApiMiddleware.Service<
348
+ SchemaFallback,
349
+ { requires: Engine.SparqlEngine | Store.QuadStore | Serialization.Formats }
350
+ >()('@thomasfosterau/effect-sparql-node/SparqlApi/SchemaFallback') {}
351
+
352
+ /**
353
+ * The paths an API is served at.
354
+ *
355
+ * @since 0.1.0
356
+ * @category models
357
+ */
358
+ export interface Paths {
359
+ /** The SPARQL endpoint, for queries and updates (default `/sparql`). */
360
+ readonly path?: `/${string}` | undefined;
361
+ /** The Graph Store endpoint (default `/rdf-graph-store`). */
362
+ readonly graphStorePath?: `/${string}` | undefined;
363
+ }
364
+
365
+ const sparqlGroup = (path: `/${string}`) =>
366
+ HttpApiGroup.make('sparql')
367
+ .add(
368
+ HttpApiEndpoint.get('query', path, {
369
+ query: QueryParams,
370
+ headers: AcceptHeader,
371
+ success: QueryResponse,
372
+ error: [BadRequest, NotAcceptable, InternalServerError, ServiceUnavailable],
373
+ }).annotateMerge(
374
+ OpenApi.annotations({
375
+ summary: 'Query, or read the service description',
376
+ description:
377
+ 'A SPARQL query in `query`, with its dataset in `default-graph-uri` / `named-graph-uri`. Without `query`, answers the service description. The response format is negotiated from `Accept`.',
378
+ }),
379
+ ),
380
+ HttpApiEndpoint.post('post', path, {
381
+ query: DatasetParams,
382
+ headers: AcceptHeader,
383
+ payload: [DirectQueryBody, DirectUpdateBody, FormBody],
384
+ success: [QueryResponse, HttpApiSchema.NoContent],
385
+ error: [
386
+ BadRequest,
387
+ Forbidden,
388
+ NotAcceptable,
389
+ UnsupportedMediaType,
390
+ InternalServerError,
391
+ ServiceUnavailable,
392
+ ],
393
+ }).annotateMerge(
394
+ OpenApi.annotations({
395
+ summary: 'Query or update',
396
+ description:
397
+ 'A query (`200` with the results) or an update (`204`), as a form or as an `application/sparql-query` / `application/sparql-update` body. A read-only endpoint answers updates with `403`.',
398
+ }),
399
+ ),
400
+ )
401
+ .annotateMerge(
402
+ OpenApi.annotations({ title: 'SPARQL', description: 'The SPARQL 1.1 Protocol' }),
403
+ );
404
+
405
+ const writeErrors = [
406
+ BadRequest,
407
+ NotFound,
408
+ MethodNotAllowed,
409
+ UnsupportedMediaType,
410
+ InternalServerError,
411
+ ] as const;
412
+
413
+ const graphStoreGroup = (path: `/${string}`) =>
414
+ HttpApiGroup.make('graphStore')
415
+ .add(
416
+ HttpApiEndpoint.get('get', path, {
417
+ query: GraphParams,
418
+ headers: AcceptHeader,
419
+ success: RdfResponse,
420
+ error: [BadRequest, NotFound, NotAcceptable, InternalServerError],
421
+ }).annotateMerge(OpenApi.annotations({ summary: 'Read a graph' })),
422
+ HttpApiEndpoint.head('head', path, {
423
+ query: GraphParams,
424
+ headers: AcceptHeader,
425
+ success: HttpApiSchema.Empty(200),
426
+ error: [BadRequest, NotFound, NotAcceptable, InternalServerError],
427
+ }).annotateMerge(OpenApi.annotations({ summary: 'Check that a graph exists' })),
428
+ HttpApiEndpoint.put('put', path, {
429
+ query: GraphParams,
430
+ payload: RdfDocumentBody,
431
+ success: [HttpApiSchema.NoContent, HttpApiSchema.Created],
432
+ error: writeErrors,
433
+ }).annotateMerge(
434
+ OpenApi.annotations({
435
+ summary: 'Replace a graph',
436
+ description: '`201` when the graph is new, `204` when it existed.',
437
+ }),
438
+ ),
439
+ HttpApiEndpoint.post('post', path, {
440
+ query: GraphParams,
441
+ payload: RdfDocumentBody,
442
+ success: [HttpApiSchema.NoContent, HttpApiSchema.Created],
443
+ error: writeErrors,
444
+ }).annotateMerge(
445
+ OpenApi.annotations({
446
+ summary: 'Add to a graph, or create one',
447
+ description:
448
+ 'Merges the document into the graph (`201` when it is new). Without `graph` or `default`, creates a new graph and answers `201` with its IRI in `Location`.',
449
+ }),
450
+ ),
451
+ HttpApiEndpoint.delete('delete', path, {
452
+ query: GraphParams,
453
+ success: HttpApiSchema.NoContent,
454
+ error: [BadRequest, NotFound, MethodNotAllowed, InternalServerError],
455
+ }).annotateMerge(OpenApi.annotations({ summary: 'Delete a graph' })),
456
+ )
457
+ .annotateMerge(
458
+ OpenApi.annotations({
459
+ title: 'Graph Store',
460
+ description:
461
+ 'The SPARQL 1.1 Graph Store HTTP Protocol (indirect identification; URLs below the graph store identify graphs directly)',
462
+ }),
463
+ );
464
+
465
+ /**
466
+ * Builds the API at the given paths.
467
+ *
468
+ * @since 0.1.0
469
+ * @category constructors
470
+ */
471
+ export const make = (paths?: Paths) =>
472
+ HttpApi.make('effect-sparql-node')
473
+ .add(sparqlGroup(paths?.path ?? '/sparql'))
474
+ .add(graphStoreGroup(paths?.graphStorePath ?? '/rdf-graph-store'))
475
+ .middleware(SchemaFallback)
476
+ .annotateMerge(
477
+ OpenApi.annotations({
478
+ title: 'SPARQL 1.1 Protocol and Graph Store HTTP Protocol',
479
+ description:
480
+ 'Every response format is negotiated from `Accept`; methods an endpoint does not support are answered with `405` and `Allow`.',
481
+ }),
482
+ );
483
+
484
+ /**
485
+ * The type of an API built by `make`.
486
+ *
487
+ * @since 0.1.0
488
+ * @category models
489
+ */
490
+ export type Api = ReturnType<typeof make>;
491
+
492
+ /**
493
+ * The API at the default paths, `/sparql` and `/rdf-graph-store`.
494
+ *
495
+ * @since 0.1.0
496
+ * @category api
497
+ */
498
+ export const SparqlApi: Api = make();
499
+
500
+ /**
501
+ * The OpenAPI document of an API (the default one when omitted).
502
+ *
503
+ * @since 0.1.0
504
+ * @category openapi
505
+ */
506
+ export const openApi = (api: Api = SparqlApi): OpenApi.OpenAPISpec => OpenApi.fromApi(api);
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Serves the `SparqlApi` definition with `HttpApiBuilder`.
3
+ *
4
+ * Every endpoint is implemented with a raw handler (`handleRaw`) that runs
5
+ * the same code as `SparqlServer`: the request body, `Accept` and
6
+ * `Content-Type` are read by the protocol internals, so responses, statuses
7
+ * and error messages are those of `SparqlServer.handler`. Routes the API does
8
+ * not declare (other methods on the endpoints, which get `405` with
9
+ * `Allow`; `OPTIONS` preflights when CORS is on; graphs identified directly
10
+ * by a URL below the graph store) are registered as plain `HttpRouter`
11
+ * fallback routes to `SparqlServer.handler`.
12
+ *
13
+ * `effect/http-api` is `@stability unstable` in Effect 4.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Layer from 'effect/Layer';
19
+ import * as Scope from 'effect/Scope';
20
+ import type * as HttpClient from 'effect/http/HttpClient';
21
+ import * as HttpRouter from 'effect/http/HttpRouter';
22
+ import * as HttpServer from 'effect/http/HttpServer';
23
+ import * as HttpServerError from 'effect/http/HttpServerError';
24
+ import * as HttpApiBuilder from 'effect/http-api/HttpApiBuilder';
25
+ import * as HttpApiMiddleware from 'effect/http-api/HttpApiMiddleware';
26
+ import type * as Options from '@thomasfosterau/effect-rdf/sparql/Options';
27
+ import * as SparqlApi from './SparqlApi.ts';
28
+ import * as SparqlServer from '@thomasfosterau/effect-rdf/sparql/SparqlServer';
29
+ import * as Testing from '@thomasfosterau/effect-rdf/sparql/Testing';
30
+
31
+ /**
32
+ * Options of the API server: the server options, with the endpoint paths of
33
+ * the API (the update endpoint is the query endpoint) and where to serve the
34
+ * OpenAPI document.
35
+ *
36
+ * @since 0.1.0
37
+ * @category models
38
+ */
39
+ export interface ApiOptions
40
+ extends Omit<Options.Options, 'path' | 'updatePath' | 'graphStorePath'>, SparqlApi.Paths {
41
+ /** Serves the OpenAPI document (`SparqlApi.openApi`) as JSON at this path. */
42
+ readonly openapiPath?: `/${string}` | undefined;
43
+ }
44
+
45
+ interface ServerPaths {
46
+ readonly path: `/${string}`;
47
+ readonly graphStorePath: `/${string}`;
48
+ }
49
+
50
+ const serverOptions = (options?: ApiOptions): Options.Options & ServerPaths => {
51
+ const path = options?.path ?? '/sparql';
52
+ return {
53
+ ...options,
54
+ path,
55
+ updatePath: path,
56
+ graphStorePath: options?.graphStorePath ?? '/rdf-graph-store',
57
+ };
58
+ };
59
+
60
+ const handlersFor = (api: SparqlApi.Api, options: Options.Options) => {
61
+ const sparql = SparqlServer.sparqlHandler(options);
62
+ const graphStore = SparqlServer.graphStoreHandler(options);
63
+ return Layer.mergeAll(
64
+ HttpApiBuilder.group(api, 'sparql', (handlers) =>
65
+ handlers.handleRaw('query', () => sparql).handleRaw('post', () => sparql),
66
+ ),
67
+ HttpApiBuilder.group(api, 'graphStore', (handlers) =>
68
+ handlers
69
+ .handleRaw('get', () => graphStore)
70
+ .handleRaw('head', () => graphStore)
71
+ .handleRaw('put', () => graphStore)
72
+ .handleRaw('post', () => graphStore)
73
+ .handleRaw('delete', () => graphStore),
74
+ ),
75
+ );
76
+ };
77
+
78
+ /**
79
+ * The handlers of both API groups (`sparql` and `graphStore`) for the API at
80
+ * the options' paths, using the server's services from the context.
81
+ *
82
+ * @since 0.1.0
83
+ * @category layers
84
+ */
85
+ export const layerHandlers = (options?: ApiOptions) => {
86
+ const resolved = serverOptions(options);
87
+ return handlersFor(SparqlApi.make(resolved), resolved);
88
+ };
89
+
90
+ /**
91
+ * The `SparqlApi.SchemaFallback` middleware: a request whose parameters or
92
+ * headers fail the API's schemas is answered by `SparqlServer.handler`, so
93
+ * it gets the protocol's own status and message (e.g. `400` "Exactly one
94
+ * 'query' parameter is allowed"). Response encoding failures stay `500`.
95
+ *
96
+ * @since 0.1.0
97
+ * @category layers
98
+ */
99
+ export const layerSchemaFallback = (
100
+ options?: ApiOptions,
101
+ ): Layer.Layer<SparqlApi.SchemaFallback> => {
102
+ const app = SparqlServer.handler(serverOptions(options));
103
+ return HttpApiMiddleware.layerSchemaErrorTransform(SparqlApi.SchemaFallback, (error) =>
104
+ error.kind === 'Body' || error.kind === 'ResponseHeaders' ? Effect.fail(error) : app,
105
+ );
106
+ };
107
+
108
+ /**
109
+ * The fallback routes: every method on `<path>*` and `<graphStorePath>*`
110
+ * that the API does not declare, answered by `SparqlServer.handler` (`405`
111
+ * with `Allow`, CORS preflights, direct graph identification, `404` for
112
+ * other paths).
113
+ *
114
+ * @since 0.1.0
115
+ * @category layers
116
+ */
117
+ export const layerFallback = (
118
+ options?: ApiOptions,
119
+ ): Layer.Layer<
120
+ never,
121
+ never,
122
+ HttpRouter.HttpRouter | HttpRouter.Request.From<'Requires', SparqlServer.Requirements>
123
+ > => {
124
+ const resolved = serverOptions(options);
125
+ const app = SparqlServer.handler(resolved);
126
+ return HttpRouter.use((router) =>
127
+ Effect.gen(function* () {
128
+ // A wildcard right after the path matches the path itself (for methods the API does not
129
+ // declare) and everything below it, without clashing with the API's own routes.
130
+ yield* router.add('*', `${resolved.path}*`, app);
131
+ yield* router.add('*', `${resolved.graphStorePath}*`, app);
132
+ }),
133
+ );
134
+ };
135
+
136
+ /**
137
+ * Registers the API on the `HttpRouter`: the `HttpApiBuilder` routes with
138
+ * their handlers and the `SchemaFallback` middleware, the fallback routes and, with `openapiPath`, the OpenAPI
139
+ * document.
140
+ *
141
+ * @since 0.1.0
142
+ * @category layers
143
+ */
144
+ export const layerRoutes = (options?: ApiOptions) => {
145
+ const resolved = serverOptions(options);
146
+ const api = SparqlApi.make(resolved);
147
+ const schemaFallback = layerSchemaFallback(options);
148
+ return Layer.mergeAll(
149
+ HttpApiBuilder.layer(api, { openapiPath: options?.openapiPath }).pipe(
150
+ Layer.provide(handlersFor(api, resolved).pipe(Layer.provide(schemaFallback))),
151
+ ),
152
+ layerFallback(options),
153
+ );
154
+ };
155
+
156
+ /**
157
+ * Serves the API routes with `HttpRouter.serve` on an `HttpServer` from the
158
+ * context (logging requests unless `logging: false`); the server layer (such
159
+ * as `NodeHttpServer.layer`) also provides the platform services
160
+ * `HttpApiBuilder` needs.
161
+ *
162
+ * @since 0.1.0
163
+ * @category layers
164
+ */
165
+ export const layerServe = (options?: ApiOptions) =>
166
+ HttpRouter.serve(layerRoutes({ ...options, logging: false }), {
167
+ disableLogger: options?.logging === false,
168
+ });
169
+
170
+ /**
171
+ * In-process testing: builds a client for the `SparqlApi` routes
172
+ * (`layerRoutes(options)`) using the server's services from
173
+ * the context. The routes live as long as the surrounding scope.
174
+ *
175
+ * @since 0.1.0
176
+ * @category constructors
177
+ */
178
+ export const makeTestClient = (
179
+ options?: ApiOptions,
180
+ ): Effect.Effect<HttpClient.HttpClient, never, SparqlServer.Requirements | Scope.Scope> =>
181
+ Effect.gen(function* () {
182
+ const scope = yield* Scope.Scope;
183
+ const context = yield* Effect.context<SparqlServer.Requirements>();
184
+ const app = yield* HttpRouter.toHttpEffect(
185
+ layerRoutes(options).pipe(Layer.provide(HttpServer.layerServices)),
186
+ );
187
+ return Testing.fromApp(
188
+ app.pipe(
189
+ Effect.catchCause((cause) =>
190
+ Effect.map(HttpServerError.causeResponse(cause), ([response]) => response),
191
+ ),
192
+ Effect.provideService(Scope.Scope, scope),
193
+ Effect.provideContext(context),
194
+ ),
195
+ );
196
+ });