@danmat/query-server 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 +86 -0
- package/dist/index.cjs +106 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +97 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Matthew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @danmat/query-server
|
|
2
|
+
|
|
3
|
+
[](https://github.com/DanMat/query-server/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@danmat/query-server)
|
|
5
|
+
[](https://bundlejs.com/?q=@danmat/query-server)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
Framework-agnostic **server** helpers for the HTTP QUERY method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)). Validate incoming QUERY requests, enforce the RFC's `Content-Type` rule, negotiate accepted query formats, and advertise them with `Accept-Query`.
|
|
9
|
+
|
|
10
|
+
Built on **Web-standard `Request`/`Response`**, so it runs anywhere they do — Hono, Deno, Bun, Cloudflare Workers, and Node (via a web adapter). Its only dependency is [`@danmat/accept-query`](https://github.com/DanMat/accept-query).
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { checkQueryRequest, readQueryJson, withAcceptQuery } from "@danmat/query-server";
|
|
14
|
+
|
|
15
|
+
const ACCEPTED = ["application/json", "application/sql"];
|
|
16
|
+
|
|
17
|
+
async function handler(request: Request): Promise<Response> {
|
|
18
|
+
// Reject non-QUERY, missing/unsupported Content-Type — with correct status codes.
|
|
19
|
+
const rejection = checkQueryRequest(request, { accept: ACCEPTED });
|
|
20
|
+
if (rejection) return withAcceptQuery(rejection, ACCEPTED);
|
|
21
|
+
|
|
22
|
+
const query = await readQueryJson<{ filter: unknown }>(request);
|
|
23
|
+
const results = await runQuery(query);
|
|
24
|
+
|
|
25
|
+
return withAcceptQuery(Response.json(results), ACCEPTED);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why?
|
|
30
|
+
|
|
31
|
+
RFC 10008 puts real obligations on the *server*: it MUST reject a QUERY whose `Content-Type` is missing, it should tell clients which query formats it accepts (via `Accept-Query`), and it needs to answer the method-override fallback that clients use when they're unsure the server speaks QUERY. This library packages those rules so your handler stays about *your* query logic.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install @danmat/query-server
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### `isQueryRequest(request, options?): boolean`
|
|
42
|
+
|
|
43
|
+
Whether a request should be handled as a QUERY. Recognizes the `QUERY` method and, by default, `POST` + `X-HTTP-Method-Override: QUERY` (the fallback used by clients like [`@danmat/query-fetch`](https://github.com/DanMat/query-fetch)). Disable with `{ allowMethodOverride: false }`.
|
|
44
|
+
|
|
45
|
+
### `assertQueryRequest(request, options?): void`
|
|
46
|
+
|
|
47
|
+
Throws a `QueryRequestError` (carrying the correct HTTP `status` and `headers`) when the request isn't a valid QUERY:
|
|
48
|
+
|
|
49
|
+
| Condition | Status | Extra |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Not a QUERY request | `405` | `Allow: QUERY` |
|
|
52
|
+
| Missing `Content-Type` | `400` | — |
|
|
53
|
+
| `Content-Type` not in `accept` | `415` | `Accept-Query: …` |
|
|
54
|
+
|
|
55
|
+
Pass `{ accept: ["application/json", …] }` to enable media-type negotiation (wildcards and parameters supported).
|
|
56
|
+
|
|
57
|
+
### `checkQueryRequest(request, options?): Response | null`
|
|
58
|
+
|
|
59
|
+
Non-throwing companion — returns a ready-to-send error `Response`, or `null` when the request is valid.
|
|
60
|
+
|
|
61
|
+
### `readQueryJson<T>(request): Promise<T>`
|
|
62
|
+
|
|
63
|
+
Reads the body as JSON, guarding the content type (`415` for a non-JSON type, `400` for malformed JSON).
|
|
64
|
+
|
|
65
|
+
### `acceptQueryHeader(mediaTypes): string`
|
|
66
|
+
|
|
67
|
+
Builds an `Accept-Query` header value from the media types you accept (strings and/or structured ranges with `q` weights).
|
|
68
|
+
|
|
69
|
+
### `withAcceptQuery(response, mediaTypes): Response`
|
|
70
|
+
|
|
71
|
+
Returns a copy of `response` with the `Accept-Query` header set — handy on both success and `415` responses.
|
|
72
|
+
|
|
73
|
+
### `QueryRequestError`
|
|
74
|
+
|
|
75
|
+
`Error` subclass with `status: number`, `headers: Record<string,string>`, and `toResponse(): Response`.
|
|
76
|
+
|
|
77
|
+
## The `@danmat` QUERY suite
|
|
78
|
+
|
|
79
|
+
- [`@danmat/query-fetch`](https://github.com/DanMat/query-fetch) — client for the QUERY method.
|
|
80
|
+
- [`@danmat/accept-query`](https://github.com/DanMat/accept-query) — parse/build/negotiate `Accept-Query`.
|
|
81
|
+
- [`@danmat/query-cache`](https://github.com/DanMat/query-cache) — body-aware response caching.
|
|
82
|
+
- **`@danmat/query-server`** — server-side request validation & negotiation *(you are here)*.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
[MIT](./LICENSE) © Dan Matthew
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var acceptQuery = require('@danmat/accept-query');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var QUERY_METHOD = "QUERY";
|
|
7
|
+
var QueryRequestError = class _QueryRequestError extends Error {
|
|
8
|
+
name = "QueryRequestError";
|
|
9
|
+
status;
|
|
10
|
+
headers;
|
|
11
|
+
constructor(message, status, headers = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.headers = headers;
|
|
15
|
+
Object.setPrototypeOf(this, _QueryRequestError.prototype);
|
|
16
|
+
}
|
|
17
|
+
/** Render this error as a JSON `Response` with the appropriate status/headers. */
|
|
18
|
+
toResponse() {
|
|
19
|
+
return new Response(JSON.stringify({ error: this.message }), {
|
|
20
|
+
status: this.status,
|
|
21
|
+
headers: { "content-type": "application/json", ...this.headers }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function isQueryRequest(request, options = {}) {
|
|
26
|
+
const { allowMethodOverride = true } = options;
|
|
27
|
+
const method = request.method.toUpperCase();
|
|
28
|
+
if (method === QUERY_METHOD) return true;
|
|
29
|
+
if (allowMethodOverride && method === "POST") {
|
|
30
|
+
return (request.headers.get("x-http-method-override") ?? "").toUpperCase() === QUERY_METHOD;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
function acceptQueryHeader(mediaTypes) {
|
|
35
|
+
return acceptQuery.formatAcceptQuery(mediaTypes);
|
|
36
|
+
}
|
|
37
|
+
function isAccepted(contentType, accept) {
|
|
38
|
+
return acceptQuery.negotiateQuery(acceptQuery.formatAcceptQuery(accept), [contentType]) !== null;
|
|
39
|
+
}
|
|
40
|
+
function assertQueryRequest(request, options = {}) {
|
|
41
|
+
const { accept, allowMethodOverride = true } = options;
|
|
42
|
+
if (!isQueryRequest(request, { allowMethodOverride })) {
|
|
43
|
+
throw new QueryRequestError(
|
|
44
|
+
`Expected a ${QUERY_METHOD} request but received ${request.method}.`,
|
|
45
|
+
405,
|
|
46
|
+
{ allow: QUERY_METHOD }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const contentType = request.headers.get("content-type");
|
|
50
|
+
if (!contentType) {
|
|
51
|
+
throw new QueryRequestError(
|
|
52
|
+
"A QUERY request must include a Content-Type (RFC 10008).",
|
|
53
|
+
400
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {
|
|
57
|
+
throw new QueryRequestError(
|
|
58
|
+
`Unsupported query media type "${contentType}".`,
|
|
59
|
+
415,
|
|
60
|
+
{ "accept-query": acceptQueryHeader(accept) }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function checkQueryRequest(request, options = {}) {
|
|
65
|
+
try {
|
|
66
|
+
assertQueryRequest(request, options);
|
|
67
|
+
return null;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof QueryRequestError) return error.toResponse();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function readQueryJson(request) {
|
|
74
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
75
|
+
if (!/\bjson\b/i.test(contentType)) {
|
|
76
|
+
throw new QueryRequestError(
|
|
77
|
+
`Expected a JSON query body but Content-Type was "${contentType || "absent"}".`,
|
|
78
|
+
415
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return await request.json();
|
|
83
|
+
} catch {
|
|
84
|
+
throw new QueryRequestError("Query body is not valid JSON.", 400);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function withAcceptQuery(response, mediaTypes) {
|
|
88
|
+
const headers = new Headers(response.headers);
|
|
89
|
+
headers.set("accept-query", acceptQueryHeader(mediaTypes));
|
|
90
|
+
return new Response(response.body, {
|
|
91
|
+
status: response.status,
|
|
92
|
+
statusText: response.statusText,
|
|
93
|
+
headers
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
exports.QUERY_METHOD = QUERY_METHOD;
|
|
98
|
+
exports.QueryRequestError = QueryRequestError;
|
|
99
|
+
exports.acceptQueryHeader = acceptQueryHeader;
|
|
100
|
+
exports.assertQueryRequest = assertQueryRequest;
|
|
101
|
+
exports.checkQueryRequest = checkQueryRequest;
|
|
102
|
+
exports.isQueryRequest = isQueryRequest;
|
|
103
|
+
exports.readQueryJson = readQueryJson;
|
|
104
|
+
exports.withAcceptQuery = withAcceptQuery;
|
|
105
|
+
//# sourceMappingURL=index.cjs.map
|
|
106
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["formatAcceptQuery","negotiateQuery"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n negotiateQuery,\n type MediaRangeInput,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(message: string, status: number, headers: Record<string, string> = {}) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(\n request: Request,\n): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { MediaRangeInput } from '@danmat/accept-query';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @danmat/query-server
|
|
5
|
+
*
|
|
6
|
+
* Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),
|
|
7
|
+
* built on Web-standard `Request`/`Response` — so they work in Hono, Deno,
|
|
8
|
+
* Bun, Cloudflare Workers, and Node (via a web adapter).
|
|
9
|
+
*
|
|
10
|
+
* Validate incoming QUERY requests, enforce the RFC's `Content-Type`
|
|
11
|
+
* requirement, negotiate the accepted query formats, and advertise them with
|
|
12
|
+
* the `Accept-Query` response header.
|
|
13
|
+
*
|
|
14
|
+
* @see https://www.rfc-editor.org/rfc/rfc10008
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The QUERY method name. */
|
|
18
|
+
declare const QUERY_METHOD = "QUERY";
|
|
19
|
+
interface QueryRequestOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as
|
|
22
|
+
* QUERY requests — the fallback that clients like `@danmat/query-fetch` use
|
|
23
|
+
* for servers that don't route QUERY natively. Default `true`.
|
|
24
|
+
*/
|
|
25
|
+
allowMethodOverride?: boolean;
|
|
26
|
+
}
|
|
27
|
+
interface QueryValidationOptions extends QueryRequestOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Media types this server accepts as query bodies. When provided, requests
|
|
30
|
+
* with an unsupported `Content-Type` are rejected with `415` and an
|
|
31
|
+
* `Accept-Query` header advertising these types.
|
|
32
|
+
*/
|
|
33
|
+
accept?: MediaRangeInput[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An error describing why a request is not a valid QUERY, carrying the HTTP
|
|
37
|
+
* status and headers that should be sent in response.
|
|
38
|
+
*/
|
|
39
|
+
declare class QueryRequestError extends Error {
|
|
40
|
+
name: string;
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly headers: Record<string, string>;
|
|
43
|
+
constructor(message: string, status: number, headers?: Record<string, string>);
|
|
44
|
+
/** Render this error as a JSON `Response` with the appropriate status/headers. */
|
|
45
|
+
toResponse(): Response;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a request should be handled as a QUERY (honoring method override). */
|
|
48
|
+
declare function isQueryRequest(request: Request, options?: QueryRequestOptions): boolean;
|
|
49
|
+
/** Build an `Accept-Query` header value from the media types a server accepts. */
|
|
50
|
+
declare function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Validate that `request` is a well-formed QUERY per RFC 10008. Throws a
|
|
53
|
+
* {@link QueryRequestError} (with the right HTTP status) when it isn't:
|
|
54
|
+
*
|
|
55
|
+
* - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)
|
|
56
|
+
* - missing `Content-Type` → `400 Bad Request`
|
|
57
|
+
* - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`
|
|
58
|
+
*/
|
|
59
|
+
declare function assertQueryRequest(request: Request, options?: QueryValidationOptions): void;
|
|
60
|
+
/**
|
|
61
|
+
* Non-throwing companion to {@link assertQueryRequest}. Returns an error
|
|
62
|
+
* `Response` to send back, or `null` if the request is valid.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const bad = checkQueryRequest(request, { accept: ["application/json"] });
|
|
67
|
+
* if (bad) return bad;
|
|
68
|
+
* const query = await readQueryJson(request);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare function checkQueryRequest(request: Request, options?: QueryValidationOptions): Response | null;
|
|
72
|
+
/**
|
|
73
|
+
* Read a QUERY request body as JSON, guarding the content type. Throws a
|
|
74
|
+
* {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).
|
|
75
|
+
*/
|
|
76
|
+
declare function readQueryJson<T = unknown>(request: Request): Promise<T>;
|
|
77
|
+
/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */
|
|
78
|
+
declare function withAcceptQuery(response: Response, mediaTypes: MediaRangeInput[]): Response;
|
|
79
|
+
|
|
80
|
+
export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { MediaRangeInput } from '@danmat/accept-query';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @danmat/query-server
|
|
5
|
+
*
|
|
6
|
+
* Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),
|
|
7
|
+
* built on Web-standard `Request`/`Response` — so they work in Hono, Deno,
|
|
8
|
+
* Bun, Cloudflare Workers, and Node (via a web adapter).
|
|
9
|
+
*
|
|
10
|
+
* Validate incoming QUERY requests, enforce the RFC's `Content-Type`
|
|
11
|
+
* requirement, negotiate the accepted query formats, and advertise them with
|
|
12
|
+
* the `Accept-Query` response header.
|
|
13
|
+
*
|
|
14
|
+
* @see https://www.rfc-editor.org/rfc/rfc10008
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The QUERY method name. */
|
|
18
|
+
declare const QUERY_METHOD = "QUERY";
|
|
19
|
+
interface QueryRequestOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as
|
|
22
|
+
* QUERY requests — the fallback that clients like `@danmat/query-fetch` use
|
|
23
|
+
* for servers that don't route QUERY natively. Default `true`.
|
|
24
|
+
*/
|
|
25
|
+
allowMethodOverride?: boolean;
|
|
26
|
+
}
|
|
27
|
+
interface QueryValidationOptions extends QueryRequestOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Media types this server accepts as query bodies. When provided, requests
|
|
30
|
+
* with an unsupported `Content-Type` are rejected with `415` and an
|
|
31
|
+
* `Accept-Query` header advertising these types.
|
|
32
|
+
*/
|
|
33
|
+
accept?: MediaRangeInput[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An error describing why a request is not a valid QUERY, carrying the HTTP
|
|
37
|
+
* status and headers that should be sent in response.
|
|
38
|
+
*/
|
|
39
|
+
declare class QueryRequestError extends Error {
|
|
40
|
+
name: string;
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly headers: Record<string, string>;
|
|
43
|
+
constructor(message: string, status: number, headers?: Record<string, string>);
|
|
44
|
+
/** Render this error as a JSON `Response` with the appropriate status/headers. */
|
|
45
|
+
toResponse(): Response;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a request should be handled as a QUERY (honoring method override). */
|
|
48
|
+
declare function isQueryRequest(request: Request, options?: QueryRequestOptions): boolean;
|
|
49
|
+
/** Build an `Accept-Query` header value from the media types a server accepts. */
|
|
50
|
+
declare function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string;
|
|
51
|
+
/**
|
|
52
|
+
* Validate that `request` is a well-formed QUERY per RFC 10008. Throws a
|
|
53
|
+
* {@link QueryRequestError} (with the right HTTP status) when it isn't:
|
|
54
|
+
*
|
|
55
|
+
* - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)
|
|
56
|
+
* - missing `Content-Type` → `400 Bad Request`
|
|
57
|
+
* - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`
|
|
58
|
+
*/
|
|
59
|
+
declare function assertQueryRequest(request: Request, options?: QueryValidationOptions): void;
|
|
60
|
+
/**
|
|
61
|
+
* Non-throwing companion to {@link assertQueryRequest}. Returns an error
|
|
62
|
+
* `Response` to send back, or `null` if the request is valid.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const bad = checkQueryRequest(request, { accept: ["application/json"] });
|
|
67
|
+
* if (bad) return bad;
|
|
68
|
+
* const query = await readQueryJson(request);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare function checkQueryRequest(request: Request, options?: QueryValidationOptions): Response | null;
|
|
72
|
+
/**
|
|
73
|
+
* Read a QUERY request body as JSON, guarding the content type. Throws a
|
|
74
|
+
* {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).
|
|
75
|
+
*/
|
|
76
|
+
declare function readQueryJson<T = unknown>(request: Request): Promise<T>;
|
|
77
|
+
/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */
|
|
78
|
+
declare function withAcceptQuery(response: Response, mediaTypes: MediaRangeInput[]): Response;
|
|
79
|
+
|
|
80
|
+
export { QUERY_METHOD, QueryRequestError, type QueryRequestOptions, type QueryValidationOptions, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { formatAcceptQuery, negotiateQuery } from '@danmat/accept-query';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var QUERY_METHOD = "QUERY";
|
|
5
|
+
var QueryRequestError = class _QueryRequestError extends Error {
|
|
6
|
+
name = "QueryRequestError";
|
|
7
|
+
status;
|
|
8
|
+
headers;
|
|
9
|
+
constructor(message, status, headers = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.headers = headers;
|
|
13
|
+
Object.setPrototypeOf(this, _QueryRequestError.prototype);
|
|
14
|
+
}
|
|
15
|
+
/** Render this error as a JSON `Response` with the appropriate status/headers. */
|
|
16
|
+
toResponse() {
|
|
17
|
+
return new Response(JSON.stringify({ error: this.message }), {
|
|
18
|
+
status: this.status,
|
|
19
|
+
headers: { "content-type": "application/json", ...this.headers }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function isQueryRequest(request, options = {}) {
|
|
24
|
+
const { allowMethodOverride = true } = options;
|
|
25
|
+
const method = request.method.toUpperCase();
|
|
26
|
+
if (method === QUERY_METHOD) return true;
|
|
27
|
+
if (allowMethodOverride && method === "POST") {
|
|
28
|
+
return (request.headers.get("x-http-method-override") ?? "").toUpperCase() === QUERY_METHOD;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function acceptQueryHeader(mediaTypes) {
|
|
33
|
+
return formatAcceptQuery(mediaTypes);
|
|
34
|
+
}
|
|
35
|
+
function isAccepted(contentType, accept) {
|
|
36
|
+
return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;
|
|
37
|
+
}
|
|
38
|
+
function assertQueryRequest(request, options = {}) {
|
|
39
|
+
const { accept, allowMethodOverride = true } = options;
|
|
40
|
+
if (!isQueryRequest(request, { allowMethodOverride })) {
|
|
41
|
+
throw new QueryRequestError(
|
|
42
|
+
`Expected a ${QUERY_METHOD} request but received ${request.method}.`,
|
|
43
|
+
405,
|
|
44
|
+
{ allow: QUERY_METHOD }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const contentType = request.headers.get("content-type");
|
|
48
|
+
if (!contentType) {
|
|
49
|
+
throw new QueryRequestError(
|
|
50
|
+
"A QUERY request must include a Content-Type (RFC 10008).",
|
|
51
|
+
400
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {
|
|
55
|
+
throw new QueryRequestError(
|
|
56
|
+
`Unsupported query media type "${contentType}".`,
|
|
57
|
+
415,
|
|
58
|
+
{ "accept-query": acceptQueryHeader(accept) }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function checkQueryRequest(request, options = {}) {
|
|
63
|
+
try {
|
|
64
|
+
assertQueryRequest(request, options);
|
|
65
|
+
return null;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof QueryRequestError) return error.toResponse();
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readQueryJson(request) {
|
|
72
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
73
|
+
if (!/\bjson\b/i.test(contentType)) {
|
|
74
|
+
throw new QueryRequestError(
|
|
75
|
+
`Expected a JSON query body but Content-Type was "${contentType || "absent"}".`,
|
|
76
|
+
415
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await request.json();
|
|
81
|
+
} catch {
|
|
82
|
+
throw new QueryRequestError("Query body is not valid JSON.", 400);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function withAcceptQuery(response, mediaTypes) {
|
|
86
|
+
const headers = new Headers(response.headers);
|
|
87
|
+
headers.set("accept-query", acceptQueryHeader(mediaTypes));
|
|
88
|
+
return new Response(response.body, {
|
|
89
|
+
status: response.status,
|
|
90
|
+
statusText: response.statusText,
|
|
91
|
+
headers
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { QUERY_METHOD, QueryRequestError, acceptQueryHeader, assertQueryRequest, checkQueryRequest, isQueryRequest, readQueryJson, withAcceptQuery };
|
|
96
|
+
//# sourceMappingURL=index.js.map
|
|
97
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-server\n *\n * Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008),\n * built on Web-standard `Request`/`Response` — so they work in Hono, Deno,\n * Bun, Cloudflare Workers, and Node (via a web adapter).\n *\n * Validate incoming QUERY requests, enforce the RFC's `Content-Type`\n * requirement, negotiate the accepted query formats, and advertise them with\n * the `Accept-Query` response header.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\nimport {\n formatAcceptQuery,\n negotiateQuery,\n type MediaRangeInput,\n} from \"@danmat/accept-query\";\n\n/** The QUERY method name. */\nexport const QUERY_METHOD = \"QUERY\";\n\nexport interface QueryRequestOptions {\n /**\n * Also accept `POST` requests carrying `X-HTTP-Method-Override: QUERY` as\n * QUERY requests — the fallback that clients like `@danmat/query-fetch` use\n * for servers that don't route QUERY natively. Default `true`.\n */\n allowMethodOverride?: boolean;\n}\n\nexport interface QueryValidationOptions extends QueryRequestOptions {\n /**\n * Media types this server accepts as query bodies. When provided, requests\n * with an unsupported `Content-Type` are rejected with `415` and an\n * `Accept-Query` header advertising these types.\n */\n accept?: MediaRangeInput[];\n}\n\n/**\n * An error describing why a request is not a valid QUERY, carrying the HTTP\n * status and headers that should be sent in response.\n */\nexport class QueryRequestError extends Error {\n override name = \"QueryRequestError\";\n readonly status: number;\n readonly headers: Record<string, string>;\n\n constructor(message: string, status: number, headers: Record<string, string> = {}) {\n super(message);\n this.status = status;\n this.headers = headers;\n Object.setPrototypeOf(this, QueryRequestError.prototype);\n }\n\n /** Render this error as a JSON `Response` with the appropriate status/headers. */\n toResponse(): Response {\n return new Response(JSON.stringify({ error: this.message }), {\n status: this.status,\n headers: { \"content-type\": \"application/json\", ...this.headers },\n });\n }\n}\n\n/** Whether a request should be handled as a QUERY (honoring method override). */\nexport function isQueryRequest(\n request: Request,\n options: QueryRequestOptions = {},\n): boolean {\n const { allowMethodOverride = true } = options;\n const method = request.method.toUpperCase();\n if (method === QUERY_METHOD) return true;\n if (allowMethodOverride && method === \"POST\") {\n return (\n (request.headers.get(\"x-http-method-override\") ?? \"\").toUpperCase() ===\n QUERY_METHOD\n );\n }\n return false;\n}\n\n/** Build an `Accept-Query` header value from the media types a server accepts. */\nexport function acceptQueryHeader(mediaTypes: MediaRangeInput[]): string {\n return formatAcceptQuery(mediaTypes);\n}\n\nfunction isAccepted(contentType: string, accept: MediaRangeInput[]): boolean {\n return negotiateQuery(formatAcceptQuery(accept), [contentType]) !== null;\n}\n\n/**\n * Validate that `request` is a well-formed QUERY per RFC 10008. Throws a\n * {@link QueryRequestError} (with the right HTTP status) when it isn't:\n *\n * - not a QUERY request → `405 Method Not Allowed` (`Allow: QUERY`)\n * - missing `Content-Type` → `400 Bad Request`\n * - unsupported `Content-Type` (when `accept` is given) → `415` + `Accept-Query`\n */\nexport function assertQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): void {\n const { accept, allowMethodOverride = true } = options;\n\n if (!isQueryRequest(request, { allowMethodOverride })) {\n throw new QueryRequestError(\n `Expected a ${QUERY_METHOD} request but received ${request.method}.`,\n 405,\n { allow: QUERY_METHOD },\n );\n }\n\n const contentType = request.headers.get(\"content-type\");\n if (!contentType) {\n throw new QueryRequestError(\n \"A QUERY request must include a Content-Type (RFC 10008).\",\n 400,\n );\n }\n\n if (accept && accept.length > 0 && !isAccepted(contentType, accept)) {\n throw new QueryRequestError(\n `Unsupported query media type \"${contentType}\".`,\n 415,\n { \"accept-query\": acceptQueryHeader(accept) },\n );\n }\n}\n\n/**\n * Non-throwing companion to {@link assertQueryRequest}. Returns an error\n * `Response` to send back, or `null` if the request is valid.\n *\n * @example\n * ```ts\n * const bad = checkQueryRequest(request, { accept: [\"application/json\"] });\n * if (bad) return bad;\n * const query = await readQueryJson(request);\n * ```\n */\nexport function checkQueryRequest(\n request: Request,\n options: QueryValidationOptions = {},\n): Response | null {\n try {\n assertQueryRequest(request, options);\n return null;\n } catch (error) {\n if (error instanceof QueryRequestError) return error.toResponse();\n throw error;\n }\n}\n\n/**\n * Read a QUERY request body as JSON, guarding the content type. Throws a\n * {@link QueryRequestError} (`415` for a non-JSON type, `400` for invalid JSON).\n */\nexport async function readQueryJson<T = unknown>(\n request: Request,\n): Promise<T> {\n const contentType = request.headers.get(\"content-type\") ?? \"\";\n if (!/\\bjson\\b/i.test(contentType)) {\n throw new QueryRequestError(\n `Expected a JSON query body but Content-Type was \"${contentType || \"absent\"}\".`,\n 415,\n );\n }\n try {\n return (await request.json()) as T;\n } catch {\n throw new QueryRequestError(\"Query body is not valid JSON.\", 400);\n }\n}\n\n/** Return a copy of `response` with an `Accept-Query` header advertising `mediaTypes`. */\nexport function withAcceptQuery(\n response: Response,\n mediaTypes: MediaRangeInput[],\n): Response {\n const headers = new Headers(response.headers);\n headers.set(\"accept-query\", acceptQueryHeader(mediaTypes));\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danmat/query-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic server helpers for the HTTP QUERY method (RFC 10008): validate requests, negotiate content types, and advertise Accept-Query. Built on Web-standard Request/Response.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"http",
|
|
7
|
+
"query",
|
|
8
|
+
"query-method",
|
|
9
|
+
"rfc10008",
|
|
10
|
+
"rfc-10008",
|
|
11
|
+
"server",
|
|
12
|
+
"middleware",
|
|
13
|
+
"accept-query",
|
|
14
|
+
"hono",
|
|
15
|
+
"content-negotiation"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"coverage": "vitest run --coverage",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"author": "Dan Matthew <dannymatthew@gmail.com>",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/DanMat/query-server.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/DanMat/query-server/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/DanMat/query-server#readme",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@danmat/accept-query": "^0.1.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
62
|
+
"tsup": "^8.3.5",
|
|
63
|
+
"typescript": "^5.7.2",
|
|
64
|
+
"vitest": "^2.1.8"
|
|
65
|
+
}
|
|
66
|
+
}
|