@jsonapi-serde/server 0.0.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 +22 -0
- package/README.md +22 -0
- package/dist/common/error.d.ts +53 -0
- package/dist/common/error.js +114 -0
- package/dist/common/index.d.ts +3 -0
- package/dist/common/index.js +3 -0
- package/dist/common/json-api.d.ts +94 -0
- package/dist/common/json-api.js +1 -0
- package/dist/common/response.d.ts +43 -0
- package/dist/common/response.js +78 -0
- package/dist/common/utils.d.ts +5 -0
- package/dist/common/utils.js +1 -0
- package/dist/http/accept.d.ts +53 -0
- package/dist/http/accept.js +114 -0
- package/dist/http/content-type.d.ts +18 -0
- package/dist/http/content-type.js +36 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.js +4 -0
- package/dist/http/media-type-parser.d.ts +34 -0
- package/dist/http/media-type-parser.js +118 -0
- package/dist/http/search-params.d.ts +31 -0
- package/dist/http/search-params.js +99 -0
- package/dist/request/body.d.ts +165 -0
- package/dist/request/body.js +273 -0
- package/dist/request/index.d.ts +2 -0
- package/dist/request/index.js +2 -0
- package/dist/request/query.d.ts +116 -0
- package/dist/request/query.js +142 -0
- package/dist/response/index.d.ts +1 -0
- package/dist/response/index.js +1 -0
- package/dist/response/internal.d.ts +6 -0
- package/dist/response/internal.js +156 -0
- package/dist/response/serializer.d.ts +154 -0
- package/dist/response/serializer.js +38 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2025-present, Ben Scholzen 'DASPRiD'
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
8
|
+
list of conditions and the following disclaimer.
|
|
9
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
and/or other materials provided with the distribution.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
14
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
15
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
17
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
18
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
19
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
20
|
+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
21
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
22
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# JSON:API Serde - Server
|
|
2
|
+
|
|
3
|
+
[](https://github.com/DASPRiD/jsonapi-serde-js/actions/workflows/test.yml)
|
|
4
|
+
[](https://codecov.io/gh/DASPRiD/jsonapi-serde-js)
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
JSON:API Serde is a framework-agnostic [JSON:API](https://jsonapi.org) serialization and deserialization library.
|
|
9
|
+
|
|
10
|
+
## Documentation
|
|
11
|
+
|
|
12
|
+
To check out docs, visit [jsonapi-serde.js.org](https://jsonapi-serde.js.org).
|
|
13
|
+
|
|
14
|
+
## Changelog
|
|
15
|
+
|
|
16
|
+
Detailed changes for each release are documented in the [CHANGELOG](https://github.com/dasprid/jsonapi-serde-js/blob/main/packages/server/CHANGELOG.md)
|
|
17
|
+
|
|
18
|
+
## License
|
|
19
|
+
|
|
20
|
+
[BSD-3-Clause](https://github.com/dasprid/jsonapi-serde-js/blob/main/LICENSE)
|
|
21
|
+
|
|
22
|
+
Copyright (c) 2025-present, Ben Scholzen 'DASPRiD'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { $ZodIssue } from "zod/v4/core";
|
|
2
|
+
import type { JsonApiErrorObject } from "./json-api.js";
|
|
3
|
+
import { JsonApiDocument } from "./response.js";
|
|
4
|
+
/**
|
|
5
|
+
* A JSON:API-compliant error wrapper
|
|
6
|
+
*
|
|
7
|
+
* Accepts one or more `JsonApiErrorObject` instances and provides status inference and document transformation.
|
|
8
|
+
*/
|
|
9
|
+
export declare class JsonApiError {
|
|
10
|
+
readonly errors: JsonApiErrorObject[];
|
|
11
|
+
readonly status: number;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new `JsonApiError` from one or more error objects
|
|
14
|
+
*
|
|
15
|
+
* Automatically infers a suitable HTTP status code.
|
|
16
|
+
*
|
|
17
|
+
* @throws {Error} when no errors are supplied
|
|
18
|
+
*/
|
|
19
|
+
constructor(errors: JsonApiErrorObject | JsonApiErrorObject[]);
|
|
20
|
+
/**
|
|
21
|
+
* Determines the appropriate HTTP status code from the error set
|
|
22
|
+
*
|
|
23
|
+
* Falls back to 500 or 400 based on presence and severity of error codes.
|
|
24
|
+
* A warning is emitted when no status codes were defined.
|
|
25
|
+
*/
|
|
26
|
+
private static determineStatusCode;
|
|
27
|
+
/**
|
|
28
|
+
* Converts the error into a full JSON:API error document
|
|
29
|
+
*/
|
|
30
|
+
toDocument(): JsonApiDocument;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Structured metadata that can be attached to a Zod issue to influence JSON:API output
|
|
34
|
+
*/
|
|
35
|
+
export declare class ZodValidationErrorParams {
|
|
36
|
+
/** Custom error code to override Zod’s default for custom errors */
|
|
37
|
+
readonly code: string;
|
|
38
|
+
/** Optional detailed explanation of the error */
|
|
39
|
+
readonly detail: string | undefined;
|
|
40
|
+
/** Optional HTTP status code (e.g., 400 or 422) */
|
|
41
|
+
readonly status: number | undefined;
|
|
42
|
+
constructor(code: string, detail?: string, status?: number);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Translates Zod validation issues into a JSON:API error document
|
|
46
|
+
*/
|
|
47
|
+
export declare class ZodValidationError extends JsonApiError {
|
|
48
|
+
constructor(errors: $ZodIssue[], source: "query" | "body");
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the `source` field of a JSON:API error based on Zod’s path
|
|
51
|
+
*/
|
|
52
|
+
private static getSource;
|
|
53
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { JsonApiDocument } from "./response.js";
|
|
2
|
+
/**
|
|
3
|
+
* A JSON:API-compliant error wrapper
|
|
4
|
+
*
|
|
5
|
+
* Accepts one or more `JsonApiErrorObject` instances and provides status inference and document transformation.
|
|
6
|
+
*/
|
|
7
|
+
export class JsonApiError {
|
|
8
|
+
errors;
|
|
9
|
+
status;
|
|
10
|
+
/**
|
|
11
|
+
* Creates a new `JsonApiError` from one or more error objects
|
|
12
|
+
*
|
|
13
|
+
* Automatically infers a suitable HTTP status code.
|
|
14
|
+
*
|
|
15
|
+
* @throws {Error} when no errors are supplied
|
|
16
|
+
*/
|
|
17
|
+
constructor(errors) {
|
|
18
|
+
this.errors = Array.isArray(errors) ? errors : [errors];
|
|
19
|
+
if (this.errors.length === 0) {
|
|
20
|
+
throw new Error("At least one error must be supplied");
|
|
21
|
+
}
|
|
22
|
+
this.status = JsonApiError.determineStatusCode(this.errors);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Determines the appropriate HTTP status code from the error set
|
|
26
|
+
*
|
|
27
|
+
* Falls back to 500 or 400 based on presence and severity of error codes.
|
|
28
|
+
* A warning is emitted when no status codes were defined.
|
|
29
|
+
*/
|
|
30
|
+
static determineStatusCode = (errors) => {
|
|
31
|
+
const uniqueStatusCodes = [
|
|
32
|
+
...new Set(errors
|
|
33
|
+
.map((error) => error.status)
|
|
34
|
+
.filter((value) => value !== undefined)
|
|
35
|
+
.map((value) => Number.parseInt(value, 10))),
|
|
36
|
+
];
|
|
37
|
+
if (uniqueStatusCodes.length === 0) {
|
|
38
|
+
console.warn("No error contained a status code, falling back to 500");
|
|
39
|
+
return 500;
|
|
40
|
+
}
|
|
41
|
+
if (uniqueStatusCodes.length === 1) {
|
|
42
|
+
return uniqueStatusCodes[0];
|
|
43
|
+
}
|
|
44
|
+
if (uniqueStatusCodes.some((statusCode) => statusCode >= 500 && statusCode < 600)) {
|
|
45
|
+
return 500;
|
|
46
|
+
}
|
|
47
|
+
return 400;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Converts the error into a full JSON:API error document
|
|
51
|
+
*/
|
|
52
|
+
toDocument() {
|
|
53
|
+
return new JsonApiDocument({
|
|
54
|
+
errors: this.errors,
|
|
55
|
+
}, this.status);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Structured metadata that can be attached to a Zod issue to influence JSON:API output
|
|
60
|
+
*/
|
|
61
|
+
export class ZodValidationErrorParams {
|
|
62
|
+
/** Custom error code to override Zod’s default for custom errors */
|
|
63
|
+
code;
|
|
64
|
+
/** Optional detailed explanation of the error */
|
|
65
|
+
detail;
|
|
66
|
+
/** Optional HTTP status code (e.g., 400 or 422) */
|
|
67
|
+
status;
|
|
68
|
+
constructor(code, detail, status) {
|
|
69
|
+
this.code = code;
|
|
70
|
+
this.detail = detail;
|
|
71
|
+
this.status = status;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Translates Zod validation issues into a JSON:API error document
|
|
76
|
+
*/
|
|
77
|
+
export class ZodValidationError extends JsonApiError {
|
|
78
|
+
constructor(errors, source) {
|
|
79
|
+
super(errors.map((error) => {
|
|
80
|
+
const params = error.code === "custom" && error.params instanceof ZodValidationErrorParams
|
|
81
|
+
? error.params
|
|
82
|
+
: null;
|
|
83
|
+
const { code, input, path, message, ...rest } = error;
|
|
84
|
+
const meta = params
|
|
85
|
+
? Object.fromEntries(Object.entries(rest).filter(([key]) => key !== "params"))
|
|
86
|
+
: rest;
|
|
87
|
+
return {
|
|
88
|
+
status: params?.status?.toString() ?? (source === "query" ? "400" : "422"),
|
|
89
|
+
code: params?.code ?? code,
|
|
90
|
+
title: message,
|
|
91
|
+
detail: params?.detail,
|
|
92
|
+
source: ZodValidationError.getSource(source, path),
|
|
93
|
+
meta: Object.keys(meta).length > 0 ? meta : undefined,
|
|
94
|
+
};
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolves the `source` field of a JSON:API error based on Zod’s path
|
|
99
|
+
*/
|
|
100
|
+
static getSource(errorSource, path) {
|
|
101
|
+
if (errorSource === "body") {
|
|
102
|
+
return { pointer: `/${path.join("/")}` };
|
|
103
|
+
}
|
|
104
|
+
if (path.length === 0) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
parameter: `${path[0].toString()}${path
|
|
109
|
+
.slice(1)
|
|
110
|
+
.map((element) => `[${element.toString}]`)
|
|
111
|
+
.join()}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Arbitrary metadata object allowed anywhere in a JSON:API document */
|
|
2
|
+
export type Meta = Record<string, unknown>;
|
|
3
|
+
/** A link object with optional attributes for rich linking information */
|
|
4
|
+
export type LinkObject = {
|
|
5
|
+
href: string;
|
|
6
|
+
rel?: string;
|
|
7
|
+
describedby?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
type?: string;
|
|
10
|
+
hreflang?: string;
|
|
11
|
+
meta?: Meta;
|
|
12
|
+
};
|
|
13
|
+
/** A link can be a plain string URL or a full `LinkObject` */
|
|
14
|
+
export type Link = LinkObject | string;
|
|
15
|
+
/** A collection of named links. Keys are optional and can be nullified */
|
|
16
|
+
export type Links<TKey extends string = string> = Partial<Record<TKey, Link | null>>;
|
|
17
|
+
/** Standard top-level links as defined by JSON:API */
|
|
18
|
+
export type TopLevelLinks = Links<"self" | "related" | "describedby" | "first" | "last" | "prev" | "next">;
|
|
19
|
+
/** A JSON:API-compliant error object */
|
|
20
|
+
export type JsonApiErrorObject = {
|
|
21
|
+
id?: string;
|
|
22
|
+
links?: Links<"about" | "type">;
|
|
23
|
+
status?: string;
|
|
24
|
+
code?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
detail?: string;
|
|
27
|
+
source?: {
|
|
28
|
+
pointer?: string;
|
|
29
|
+
parameter?: string;
|
|
30
|
+
header?: string;
|
|
31
|
+
};
|
|
32
|
+
meta?: Meta;
|
|
33
|
+
};
|
|
34
|
+
export type JsonApiImplementation = {
|
|
35
|
+
version?: string;
|
|
36
|
+
ext?: string[];
|
|
37
|
+
profile?: string[];
|
|
38
|
+
meta?: Meta;
|
|
39
|
+
};
|
|
40
|
+
/** Optional fields allowed at the top level of a JSON:API document */
|
|
41
|
+
type OptionalTopLevelMembers = {
|
|
42
|
+
links?: TopLevelLinks;
|
|
43
|
+
included?: Resource[];
|
|
44
|
+
};
|
|
45
|
+
/** A data document contains a primary resource (`data`) and may include `meta` */
|
|
46
|
+
type DataTopLevelMembers = {
|
|
47
|
+
data: Resource | Resource[] | null;
|
|
48
|
+
errors?: undefined;
|
|
49
|
+
meta?: Meta;
|
|
50
|
+
};
|
|
51
|
+
/** An error document includes one or more errors, but no primary data */
|
|
52
|
+
type ErrorTopLevelMembers = {
|
|
53
|
+
data?: undefined;
|
|
54
|
+
errors: JsonApiErrorObject[];
|
|
55
|
+
meta?: Meta;
|
|
56
|
+
};
|
|
57
|
+
/** A meta-only document contains only `meta`, no data or errors */
|
|
58
|
+
type MetaTopLevelMembers = {
|
|
59
|
+
data?: undefined;
|
|
60
|
+
errors?: undefined;
|
|
61
|
+
meta: Meta;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The top-level members of a JSON:API document
|
|
65
|
+
*
|
|
66
|
+
* One of `data`, `errors`, or `meta` must be present.
|
|
67
|
+
*/
|
|
68
|
+
export type TopLevelMembers = OptionalTopLevelMembers & (DataTopLevelMembers | ErrorTopLevelMembers | MetaTopLevelMembers);
|
|
69
|
+
/** Resource attributes are key-value pairs */
|
|
70
|
+
export type Attributes = Record<string, unknown>;
|
|
71
|
+
/** A map of relationship names to their definitions */
|
|
72
|
+
export type Relationships = Record<string, Relationship>;
|
|
73
|
+
/** A reference to another resource */
|
|
74
|
+
export type ResourceIdentifier = {
|
|
75
|
+
type: string;
|
|
76
|
+
id: string;
|
|
77
|
+
meta?: Meta;
|
|
78
|
+
};
|
|
79
|
+
/** A relationship describes links and/or resource identifiers */
|
|
80
|
+
export type Relationship = {
|
|
81
|
+
data?: ResourceIdentifier | ResourceIdentifier[] | null;
|
|
82
|
+
links?: Links<"self" | "related" | string>;
|
|
83
|
+
meta?: Meta;
|
|
84
|
+
};
|
|
85
|
+
/** A full resource object in the JSON:API format */
|
|
86
|
+
export type Resource = {
|
|
87
|
+
id: string;
|
|
88
|
+
type: string;
|
|
89
|
+
attributes?: Partial<Attributes>;
|
|
90
|
+
relationships?: Partial<Relationships>;
|
|
91
|
+
links?: Links<"self" | string>;
|
|
92
|
+
meta?: Meta;
|
|
93
|
+
};
|
|
94
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { JsonApiMediaType } from "../http/index.js";
|
|
2
|
+
import type { JsonApiImplementation, TopLevelMembers } from "./json-api.js";
|
|
3
|
+
type MediaTypeOptions = {
|
|
4
|
+
extensions?: string[];
|
|
5
|
+
profiles?: string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Represents a JSON:API-compliant HTTP response document
|
|
9
|
+
*
|
|
10
|
+
* Encapsulates status, headers, and body generation.
|
|
11
|
+
*/
|
|
12
|
+
export declare class JsonApiDocument {
|
|
13
|
+
private readonly members;
|
|
14
|
+
private readonly mediaTypeOptions;
|
|
15
|
+
private readonly status;
|
|
16
|
+
/**
|
|
17
|
+
* Constructs a new JSON:API response document
|
|
18
|
+
*/
|
|
19
|
+
constructor(members: TopLevelMembers, status?: number, mediaTypeOptions?: MediaTypeOptions);
|
|
20
|
+
/**
|
|
21
|
+
* Returns the HTTP status code for this response
|
|
22
|
+
*/
|
|
23
|
+
getStatus(): number;
|
|
24
|
+
/**
|
|
25
|
+
* Returns the top-level JSON:API body object
|
|
26
|
+
*/
|
|
27
|
+
getBody(): TopLevelMembers & {
|
|
28
|
+
jsonapi: JsonApiImplementation;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Returns the full `Content-Type` header for this response
|
|
32
|
+
*
|
|
33
|
+
* Automatically includes `ext` and `profile` parameters if present in `jsonapi`.
|
|
34
|
+
*/
|
|
35
|
+
getContentType(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Verifies that the client accepts the content type of this document
|
|
38
|
+
*
|
|
39
|
+
* @throws {JsonApiError} if content type is not acceptable
|
|
40
|
+
*/
|
|
41
|
+
verifyAcceptMediaType(acceptableTypes: JsonApiMediaType[]): void;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { JsonApiError } from "./error.js";
|
|
2
|
+
/**
|
|
3
|
+
* Represents a JSON:API-compliant HTTP response document
|
|
4
|
+
*
|
|
5
|
+
* Encapsulates status, headers, and body generation.
|
|
6
|
+
*/
|
|
7
|
+
export class JsonApiDocument {
|
|
8
|
+
members;
|
|
9
|
+
mediaTypeOptions;
|
|
10
|
+
status;
|
|
11
|
+
/**
|
|
12
|
+
* Constructs a new JSON:API response document
|
|
13
|
+
*/
|
|
14
|
+
constructor(members, status = 200, mediaTypeOptions) {
|
|
15
|
+
this.members = members;
|
|
16
|
+
this.mediaTypeOptions = mediaTypeOptions;
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Returns the HTTP status code for this response
|
|
21
|
+
*/
|
|
22
|
+
getStatus() {
|
|
23
|
+
return this.status;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the top-level JSON:API body object
|
|
27
|
+
*/
|
|
28
|
+
getBody() {
|
|
29
|
+
return {
|
|
30
|
+
jsonapi: {
|
|
31
|
+
version: "1.1",
|
|
32
|
+
ext: this.mediaTypeOptions?.extensions,
|
|
33
|
+
profile: this.mediaTypeOptions?.profiles,
|
|
34
|
+
},
|
|
35
|
+
...this.members,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns the full `Content-Type` header for this response
|
|
40
|
+
*
|
|
41
|
+
* Automatically includes `ext` and `profile` parameters if present in `jsonapi`.
|
|
42
|
+
*/
|
|
43
|
+
getContentType() {
|
|
44
|
+
const contentType = "application/vnd.api+json";
|
|
45
|
+
const parameters = [];
|
|
46
|
+
if (this.mediaTypeOptions?.extensions && this.mediaTypeOptions.extensions.length > 0) {
|
|
47
|
+
parameters.push(`ext="${this.mediaTypeOptions.extensions.join(" ")}"`);
|
|
48
|
+
}
|
|
49
|
+
if (this.mediaTypeOptions?.profiles && this.mediaTypeOptions.profiles.length > 0) {
|
|
50
|
+
parameters.push(`profile="${this.mediaTypeOptions.profiles.join(" ")}"`);
|
|
51
|
+
}
|
|
52
|
+
if (parameters.length === 0) {
|
|
53
|
+
return contentType;
|
|
54
|
+
}
|
|
55
|
+
return `${contentType};${parameters.join(";")}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Verifies that the client accepts the content type of this document
|
|
59
|
+
*
|
|
60
|
+
* @throws {JsonApiError} if content type is not acceptable
|
|
61
|
+
*/
|
|
62
|
+
verifyAcceptMediaType(acceptableTypes) {
|
|
63
|
+
const appliedExtensions = this.mediaTypeOptions?.extensions;
|
|
64
|
+
const matchingTypes = acceptableTypes.filter((type) => {
|
|
65
|
+
return type.ext.every((extension) => appliedExtensions?.includes(extension));
|
|
66
|
+
});
|
|
67
|
+
if (matchingTypes.length > 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new JsonApiError({
|
|
71
|
+
status: "406",
|
|
72
|
+
code: "not_acceptable",
|
|
73
|
+
title: "Not Acceptable",
|
|
74
|
+
detail: "No valid accept types provided, you must accept application/vnd.api+json",
|
|
75
|
+
meta: appliedExtensions ? { appliedExtensions } : undefined,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MediaTypeParser } from "./media-type-parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parsed representation of a single media type in the Accept header
|
|
4
|
+
*/
|
|
5
|
+
export type ParsedAcceptMediaType = {
|
|
6
|
+
type: string;
|
|
7
|
+
subType: string;
|
|
8
|
+
parameters: Record<string, string>;
|
|
9
|
+
weight: number;
|
|
10
|
+
acceptExt: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* List of all parsed media types sorted by descending quality (`q`) weight
|
|
14
|
+
*/
|
|
15
|
+
export type ParsedAccept = ParsedAcceptMediaType[];
|
|
16
|
+
/**
|
|
17
|
+
* Parses and represents an HTTP `Accept` header as structured data
|
|
18
|
+
*/
|
|
19
|
+
export declare class AcceptParser extends MediaTypeParser {
|
|
20
|
+
private static readonly default;
|
|
21
|
+
private static readonly weightRegexp;
|
|
22
|
+
/**
|
|
23
|
+
* Parses the Accept header string into a structured `ParsedAccept` array
|
|
24
|
+
*
|
|
25
|
+
* @throws {MediaTypeParserError} when parsing of Accept header fails
|
|
26
|
+
*/
|
|
27
|
+
static parse(header: string): ParsedAccept;
|
|
28
|
+
/**
|
|
29
|
+
* Parses the header into an ordered list of media types
|
|
30
|
+
*/
|
|
31
|
+
private process;
|
|
32
|
+
private readMediaType;
|
|
33
|
+
private readSeparator;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Represents a parsed and filtered JSON:API media type with `ext` and `profile` parameters
|
|
37
|
+
*/
|
|
38
|
+
export type JsonApiMediaType = {
|
|
39
|
+
ext: string[];
|
|
40
|
+
profile: string[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Filters and extracts valid JSON:API media types from an `Accept` header
|
|
44
|
+
*
|
|
45
|
+
* Only includes entries with:
|
|
46
|
+
*
|
|
47
|
+
* - `type` of `application` or `*`
|
|
48
|
+
* - `subType` of `vnd.api+json` or `*`
|
|
49
|
+
* - No extra parameters beyond `ext` and `profile`
|
|
50
|
+
*
|
|
51
|
+
* @throws {MediaTypeParserError} when parsing of Accept header fails
|
|
52
|
+
*/
|
|
53
|
+
export declare const getAcceptableMediaTypes: (header: string | undefined) => JsonApiMediaType[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { MediaTypeParser, MediaTypeParserError } from "./media-type-parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parses and represents an HTTP `Accept` header as structured data
|
|
4
|
+
*/
|
|
5
|
+
export class AcceptParser extends MediaTypeParser {
|
|
6
|
+
static default = {
|
|
7
|
+
type: "*",
|
|
8
|
+
subType: "*",
|
|
9
|
+
parameters: {},
|
|
10
|
+
weight: 1,
|
|
11
|
+
acceptExt: {},
|
|
12
|
+
};
|
|
13
|
+
static weightRegexp = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
|
|
14
|
+
/**
|
|
15
|
+
* Parses the Accept header string into a structured `ParsedAccept` array
|
|
16
|
+
*
|
|
17
|
+
* @throws {MediaTypeParserError} when parsing of Accept header fails
|
|
18
|
+
*/
|
|
19
|
+
static parse(header) {
|
|
20
|
+
const parser = new AcceptParser(header);
|
|
21
|
+
return parser.process();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parses the header into an ordered list of media types
|
|
25
|
+
*/
|
|
26
|
+
process() {
|
|
27
|
+
this.skipWhitespace();
|
|
28
|
+
if (this.index === this.length) {
|
|
29
|
+
return [AcceptParser.default];
|
|
30
|
+
}
|
|
31
|
+
const accept = [];
|
|
32
|
+
let mediaType;
|
|
33
|
+
let hasMore;
|
|
34
|
+
do {
|
|
35
|
+
[mediaType, hasMore] = this.readMediaType();
|
|
36
|
+
accept.push(mediaType);
|
|
37
|
+
} while (hasMore);
|
|
38
|
+
accept.sort((a, b) => b.weight - a.weight);
|
|
39
|
+
return accept;
|
|
40
|
+
}
|
|
41
|
+
readMediaType() {
|
|
42
|
+
const type = this.readToken().toLowerCase();
|
|
43
|
+
this.consumeChar("/");
|
|
44
|
+
const subType = this.readToken().toLowerCase();
|
|
45
|
+
this.skipWhitespace();
|
|
46
|
+
if (this.index === this.length) {
|
|
47
|
+
return [{ ...AcceptParser.default, type, subType }, false];
|
|
48
|
+
}
|
|
49
|
+
if (this.readSeparator() === ",") {
|
|
50
|
+
this.skipWhitespace();
|
|
51
|
+
return [{ ...AcceptParser.default, type, subType }, true];
|
|
52
|
+
}
|
|
53
|
+
const parameters = {};
|
|
54
|
+
let weight = 1;
|
|
55
|
+
const acceptExt = {};
|
|
56
|
+
let parameterTarget = parameters;
|
|
57
|
+
for (const [name, value] of this.readParameters(true)) {
|
|
58
|
+
if (name === "q") {
|
|
59
|
+
parameterTarget = acceptExt;
|
|
60
|
+
if (!AcceptParser.weightRegexp.test(value)) {
|
|
61
|
+
throw new MediaTypeParserError(`Invalid weight: ${value}`);
|
|
62
|
+
}
|
|
63
|
+
weight = Number.parseFloat(value);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
parameterTarget[name] = value;
|
|
67
|
+
}
|
|
68
|
+
this.skipWhitespace();
|
|
69
|
+
const hasMore = this.index < this.length;
|
|
70
|
+
if (hasMore) {
|
|
71
|
+
this.consumeChar(",");
|
|
72
|
+
this.skipWhitespace();
|
|
73
|
+
}
|
|
74
|
+
return [{ type, subType, parameters, weight, acceptExt }, hasMore];
|
|
75
|
+
}
|
|
76
|
+
readSeparator() {
|
|
77
|
+
// No need for an index check here, as the caller already took care of it.
|
|
78
|
+
const char = this.header[this.index];
|
|
79
|
+
this.index += 1;
|
|
80
|
+
if (char !== "," && char !== ";") {
|
|
81
|
+
throw new MediaTypeParserError(`Unexpected character at pos ${this.index - 1}, expected separator`);
|
|
82
|
+
}
|
|
83
|
+
return char;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Filters and extracts valid JSON:API media types from an `Accept` header
|
|
88
|
+
*
|
|
89
|
+
* Only includes entries with:
|
|
90
|
+
*
|
|
91
|
+
* - `type` of `application` or `*`
|
|
92
|
+
* - `subType` of `vnd.api+json` or `*`
|
|
93
|
+
* - No extra parameters beyond `ext` and `profile`
|
|
94
|
+
*
|
|
95
|
+
* @throws {MediaTypeParserError} when parsing of Accept header fails
|
|
96
|
+
*/
|
|
97
|
+
export const getAcceptableMediaTypes = (header) => {
|
|
98
|
+
const accept = AcceptParser.parse(header ?? "");
|
|
99
|
+
return accept.reduce((accept, mediaType) => {
|
|
100
|
+
if ((mediaType.type !== "*" && mediaType.type !== "application") ||
|
|
101
|
+
(mediaType.subType !== "*" && mediaType.subType !== "vnd.api+json")) {
|
|
102
|
+
return accept;
|
|
103
|
+
}
|
|
104
|
+
const { ext, profile, ...rest } = mediaType.parameters;
|
|
105
|
+
if (Object.keys(rest).length !== 0) {
|
|
106
|
+
return accept;
|
|
107
|
+
}
|
|
108
|
+
accept.push({
|
|
109
|
+
ext: ext ? ext.split(" ") : [],
|
|
110
|
+
profile: profile ? profile.split(" ") : [],
|
|
111
|
+
});
|
|
112
|
+
return accept;
|
|
113
|
+
}, []);
|
|
114
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MediaTypeParser } from "./media-type-parser.js";
|
|
2
|
+
export type ParsedContentType = {
|
|
3
|
+
type: string;
|
|
4
|
+
subType: string;
|
|
5
|
+
parameters: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Parses and represents an HTTP `Content-Type` header as structured data
|
|
9
|
+
*/
|
|
10
|
+
export declare class ContentTypeParser extends MediaTypeParser {
|
|
11
|
+
/**
|
|
12
|
+
* Parses the Content-Type header string into a structured `ParsedContentType` object
|
|
13
|
+
*
|
|
14
|
+
* @throws {ParserError} when parsing of Content-Type header fails
|
|
15
|
+
*/
|
|
16
|
+
static parse(header: string): ParsedContentType;
|
|
17
|
+
private process;
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MediaTypeParser, MediaTypeParserError } from "./media-type-parser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parses and represents an HTTP `Content-Type` header as structured data
|
|
4
|
+
*/
|
|
5
|
+
export class ContentTypeParser extends MediaTypeParser {
|
|
6
|
+
/**
|
|
7
|
+
* Parses the Content-Type header string into a structured `ParsedContentType` object
|
|
8
|
+
*
|
|
9
|
+
* @throws {ParserError} when parsing of Content-Type header fails
|
|
10
|
+
*/
|
|
11
|
+
static parse(header) {
|
|
12
|
+
const parser = new ContentTypeParser(header);
|
|
13
|
+
return parser.process();
|
|
14
|
+
}
|
|
15
|
+
process() {
|
|
16
|
+
this.skipWhitespace();
|
|
17
|
+
const type = this.readToken().toLowerCase();
|
|
18
|
+
this.consumeChar("/");
|
|
19
|
+
const subType = this.readToken().toLowerCase();
|
|
20
|
+
const parameters = {};
|
|
21
|
+
this.skipWhitespace();
|
|
22
|
+
if (this.index === this.length) {
|
|
23
|
+
return { type, subType, parameters };
|
|
24
|
+
}
|
|
25
|
+
if (this.header[this.index] !== ";") {
|
|
26
|
+
throw new MediaTypeParserError(`Unexpected character at pos ${this.index}, expected separator`);
|
|
27
|
+
}
|
|
28
|
+
this.index += 1;
|
|
29
|
+
if (this.index < this.length) {
|
|
30
|
+
for (const [name, value] of this.readParameters(false)) {
|
|
31
|
+
parameters[name] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { type, subType, parameters };
|
|
35
|
+
}
|
|
36
|
+
}
|