@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.
- package/LICENSE +21 -0
- package/README.md +219 -1
- package/bin/effect-sparql-server.mjs +26 -0
- package/dist/Cli-DE0eXMJr.d.mts +143 -0
- package/dist/Cli-DFvng1N8.mjs +259 -0
- package/dist/Cli-DFvng1N8.mjs.map +1 -0
- package/dist/Cli.d.mts +2 -0
- package/dist/Cli.mjs +1 -0
- package/dist/NodeSparqlServer-1vSrPk3J.d.mts +41 -0
- package/dist/NodeSparqlServer-CYAd_6BG.mjs +50 -0
- package/dist/NodeSparqlServer-CYAd_6BG.mjs.map +1 -0
- package/dist/NodeSparqlServer.d.mts +2 -0
- package/dist/NodeSparqlServer.mjs +1 -0
- package/dist/SparqlApi-BKE9nIf4.d.mts +488 -0
- package/dist/SparqlApi-BuycDkWu.mjs +371 -0
- package/dist/SparqlApi-BuycDkWu.mjs.map +1 -0
- package/dist/SparqlApi.d.mts +2 -0
- package/dist/SparqlApi.mjs +1 -0
- package/dist/SparqlApiBuilder-BAMpMy-Z.d.mts +83 -0
- package/dist/SparqlApiBuilder-GT7EopBq.mjs +136 -0
- package/dist/SparqlApiBuilder-GT7EopBq.mjs.map +1 -0
- package/dist/SparqlApiBuilder.d.mts +2 -0
- package/dist/SparqlApiBuilder.mjs +1 -0
- package/dist/SparqlApiClient-C82RGMEF.d.mts +386 -0
- package/dist/SparqlApiClient-CJcoFy5b.mjs +203 -0
- package/dist/SparqlApiClient-CJcoFy5b.mjs.map +1 -0
- package/dist/SparqlApiClient.d.mts +2 -0
- package/dist/SparqlApiClient.mjs +1 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +6 -0
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/package.json +59 -3
- package/src/Cli.ts +373 -0
- package/src/NodeSparqlServer.ts +71 -0
- package/src/SparqlApi.ts +506 -0
- package/src/SparqlApiBuilder.ts +196 -0
- package/src/SparqlApiClient.ts +383 -0
- package/src/index.ts +20 -0
package/src/SparqlApi.ts
ADDED
|
@@ -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
|
+
});
|