@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { JsonApiDocument } from "../common/response.js";
|
|
3
|
+
/**
|
|
4
|
+
* Serializes one or more entities into a JSON:API-compliant document
|
|
5
|
+
*/
|
|
6
|
+
export const serializeDocument = (map, type, entity, options) => {
|
|
7
|
+
let data;
|
|
8
|
+
const includeCollection = new IncludeCollection(map, options);
|
|
9
|
+
if (entity === null) {
|
|
10
|
+
data = null;
|
|
11
|
+
}
|
|
12
|
+
else if (Symbol.iterator in entity) {
|
|
13
|
+
data = [];
|
|
14
|
+
for (const singleEntity of entity) {
|
|
15
|
+
data.push(serializeEntityToResource(map, type, singleEntity, includeCollection, "", options?.fields, options?.context));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
data = serializeEntityToResource(map, type, entity, includeCollection, "", options?.fields, options?.context);
|
|
20
|
+
}
|
|
21
|
+
return new JsonApiDocument({
|
|
22
|
+
data,
|
|
23
|
+
included: includeCollection.toJson(),
|
|
24
|
+
links: options?.links,
|
|
25
|
+
meta: options?.meta,
|
|
26
|
+
}, options?.status, { extensions: options?.extensions, profiles: options?.profiles });
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extracts a relationship's resource identifier, adding the full entity to the included collection if needed
|
|
30
|
+
*/
|
|
31
|
+
const extractIncluded = (identifier, includedCollection, fieldPath) => {
|
|
32
|
+
const { entity, ...rest } = identifier;
|
|
33
|
+
if (entity) {
|
|
34
|
+
includedCollection.add(identifier.type, entity, fieldPath);
|
|
35
|
+
}
|
|
36
|
+
return rest;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Builds a JSON:API-compliant relationships object for a serialized entity
|
|
40
|
+
*/
|
|
41
|
+
const buildRelationships = (serializedEntity, includeCollection, parentPath) => {
|
|
42
|
+
if (!serializedEntity.relationships) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const relationships = {};
|
|
46
|
+
for (const [key, relationship] of Object.entries(serializedEntity.relationships)) {
|
|
47
|
+
/* node:coverage disable */
|
|
48
|
+
if (!relationship) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
/* node:coverage enable */
|
|
52
|
+
const { data, ...rest } = relationship;
|
|
53
|
+
if (!data) {
|
|
54
|
+
relationships[key] = { ...rest, data };
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const path = parentPath === "" ? key : `${parentPath}.${key}`;
|
|
58
|
+
if (Array.isArray(data)) {
|
|
59
|
+
relationships[key] = {
|
|
60
|
+
...rest,
|
|
61
|
+
data: data.map((data) => extractIncluded(data, includeCollection, path)),
|
|
62
|
+
};
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
relationships[key] = {
|
|
66
|
+
...rest,
|
|
67
|
+
data: extractIncluded(data, includeCollection, path),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return relationships;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Filters object fields based on an allowlist of field names
|
|
74
|
+
*/
|
|
75
|
+
const filterFields = (values, includedFields) => {
|
|
76
|
+
if (!values) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (!includedFields) {
|
|
80
|
+
return Object.keys(values).length === 0 ? undefined : values;
|
|
81
|
+
}
|
|
82
|
+
const entries = Object.entries(values).filter(([key]) => includedFields.includes(key));
|
|
83
|
+
if (entries.length === 0) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return Object.fromEntries(entries);
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Converts a single entity into a JSON:API resource object
|
|
90
|
+
*/
|
|
91
|
+
const serializeEntityToResource = (map, type, entity, includeCollection, parentPath, fields, context) => {
|
|
92
|
+
const serializedEntity = map[type].serialize(entity, context?.[type]);
|
|
93
|
+
const includedFields = fields?.[type];
|
|
94
|
+
return {
|
|
95
|
+
id: map[type].getId(entity),
|
|
96
|
+
type,
|
|
97
|
+
attributes: filterFields(serializedEntity.attributes, includedFields),
|
|
98
|
+
relationships: filterFields(buildRelationships(serializedEntity, includeCollection, parentPath), includedFields),
|
|
99
|
+
meta: serializedEntity.meta,
|
|
100
|
+
links: serializedEntity.links,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Tracks which related entities should be included in the top-level `included` array
|
|
105
|
+
*/
|
|
106
|
+
class IncludeCollection {
|
|
107
|
+
map;
|
|
108
|
+
options;
|
|
109
|
+
resources = [];
|
|
110
|
+
alreadyIncluded = new Set();
|
|
111
|
+
/**
|
|
112
|
+
* Creates a new include collection based on inclusion rules
|
|
113
|
+
*/
|
|
114
|
+
constructor(map, options) {
|
|
115
|
+
this.map = map;
|
|
116
|
+
this.options = options;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Adds an entity to the included set if it's not already present and matches an include path
|
|
120
|
+
*/
|
|
121
|
+
add(type, entity, fieldPath) {
|
|
122
|
+
if (!this.shouldInclude(fieldPath)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
assert(this.map[type], `Type '${type}' is not registered in serializer`);
|
|
126
|
+
const key = `${type}\0${this.map[type].getId(entity)}`;
|
|
127
|
+
if (this.alreadyIncluded.has(key)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.alreadyIncluded.add(key);
|
|
131
|
+
this.resources.push(serializeEntityToResource(this.map, type, entity, this, fieldPath, this.options?.fields, this.options?.context));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Converts the included resource set to JSON:API format
|
|
135
|
+
*/
|
|
136
|
+
toJson() {
|
|
137
|
+
if (!this.options?.include || this.options.include.length === 0) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return this.resources;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Checks if a given path should be included in the document
|
|
144
|
+
*/
|
|
145
|
+
shouldInclude(fieldPath) {
|
|
146
|
+
if (!this.options?.include) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
for (const includePath of this.options.include) {
|
|
150
|
+
if (includePath === fieldPath || includePath.startsWith(`${fieldPath}.`)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Attributes, Links, Meta, Relationship, ResourceIdentifier, TopLevelLinks } from "../common/json-api.js";
|
|
2
|
+
import type { JsonApiDocument } from "../common/response.js";
|
|
3
|
+
import type { Flatten } from "../common/utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* A resource identifier with an optional `entity` field
|
|
6
|
+
*
|
|
7
|
+
* Used to enable inclusion of full resource data in `included`.
|
|
8
|
+
*/
|
|
9
|
+
export type IncludableResourceIdentifier<TMap extends SerializeMap, TType extends keyof TMap & string = keyof TMap & string> = Omit<ResourceIdentifier, "type"> & {
|
|
10
|
+
type: TType;
|
|
11
|
+
entity?: InferEntity<TMap[TType]> | undefined;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A relationship object that supports including full entities
|
|
15
|
+
*/
|
|
16
|
+
export type IncludableRelationship<TMap extends SerializeMap> = Omit<Relationship, "data"> & {
|
|
17
|
+
data: IncludableResourceIdentifier<TMap> | IncludableResourceIdentifier<TMap>[] | null;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The structure returned by an `EntitySerializer`
|
|
21
|
+
*
|
|
22
|
+
* This mimics a partial JSON:API resource object.
|
|
23
|
+
*/
|
|
24
|
+
export type SerializedEntity<TMap extends SerializeMap> = {
|
|
25
|
+
attributes?: Partial<Attributes>;
|
|
26
|
+
relationships?: Partial<Record<string, IncludableRelationship<TMap>>>;
|
|
27
|
+
meta?: Meta;
|
|
28
|
+
links?: Links;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Context passed to individual entity serializers
|
|
32
|
+
*/
|
|
33
|
+
export type EntitySerializerContext = Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* A serializer that converts a specific entity type into a partial resource object
|
|
36
|
+
*/
|
|
37
|
+
export type EntitySerializer<T extends object, TContext extends EntitySerializerContext = EntitySerializerContext, TMap extends SerializeMap = SerializeMap> = {
|
|
38
|
+
getId: (entity: T) => string;
|
|
39
|
+
serialize: (entity: T, context?: TContext) => SerializedEntity<TMap>;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Infers the entity type from an EntitySerializer
|
|
43
|
+
*/
|
|
44
|
+
export type InferEntity<T extends EntitySerializer<object>> = T extends EntitySerializer<infer U> ? U : never;
|
|
45
|
+
/**
|
|
46
|
+
* Infers the context type from an EntitySerializer
|
|
47
|
+
*/
|
|
48
|
+
export type InferContext<T extends EntitySerializer<object>> = T extends EntitySerializer<any, infer U> ? U : never;
|
|
49
|
+
/**
|
|
50
|
+
* A generic serializer type used for mapping any entity
|
|
51
|
+
*
|
|
52
|
+
* biome-ignore lint/suspicious/noExplicitAny: Required for inference
|
|
53
|
+
*/
|
|
54
|
+
export type AnyEntitySerializer = EntitySerializer<any>;
|
|
55
|
+
/**
|
|
56
|
+
* A map from resource type to their associated serializer
|
|
57
|
+
*/
|
|
58
|
+
export type SerializeMap = {
|
|
59
|
+
[key: string]: AnyEntitySerializer;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Options for customizing serialization output
|
|
63
|
+
*/
|
|
64
|
+
export type SerializeOptions<TMap extends SerializeMap> = {
|
|
65
|
+
/**
|
|
66
|
+
* Context for serializers
|
|
67
|
+
*/
|
|
68
|
+
context?: Partial<SerializerContext<TMap>>;
|
|
69
|
+
/**
|
|
70
|
+
* HTTP status code to include in the response
|
|
71
|
+
*/
|
|
72
|
+
status?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Relationship paths to include in `included`
|
|
75
|
+
*/
|
|
76
|
+
include?: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Fields to include for each resource type
|
|
79
|
+
*/
|
|
80
|
+
fields?: Partial<Record<keyof TMap & string, string[]>>;
|
|
81
|
+
/**
|
|
82
|
+
* Top-level `links` object
|
|
83
|
+
*/
|
|
84
|
+
links?: TopLevelLinks;
|
|
85
|
+
/**
|
|
86
|
+
* Top-level `meta` object
|
|
87
|
+
*/
|
|
88
|
+
meta?: Meta;
|
|
89
|
+
/**
|
|
90
|
+
* JSON:API extension URIs
|
|
91
|
+
*/
|
|
92
|
+
extensions?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* JSON:API profile URIs
|
|
95
|
+
*/
|
|
96
|
+
profiles?: string[];
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Context passed down to entity serializers
|
|
100
|
+
*/
|
|
101
|
+
export type SerializerContext<TMap extends SerializeMap> = {
|
|
102
|
+
[K in keyof TMap]: InferContext<TMap[K]>;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* A function that serializes one or more entities into a JSON:API document
|
|
106
|
+
*/
|
|
107
|
+
export type Serializer<TMap extends SerializeMap> = <TType extends keyof TMap & string>(type: TType, entity: InferEntity<TMap[TType]> | Iterable<InferEntity<TMap[TType]>> | null, options?: SerializeOptions<TMap>) => JsonApiDocument;
|
|
108
|
+
/**
|
|
109
|
+
* Infers the serialize map from a serializer
|
|
110
|
+
*/
|
|
111
|
+
export type InferSerializeMap<T extends Serializer<SerializeMap>> = T extends Serializer<infer U> ? U : never;
|
|
112
|
+
/**
|
|
113
|
+
* Infers the serialize entity from a serializer
|
|
114
|
+
*/
|
|
115
|
+
export type InferSerializedEntity<T extends Serializer<SerializeMap>> = SerializedEntity<InferSerializeMap<T>>;
|
|
116
|
+
/**
|
|
117
|
+
* Empty map used as initial state for the builder
|
|
118
|
+
*/
|
|
119
|
+
type EmptyMap = {
|
|
120
|
+
[key: string]: never;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Utility type to merge two `SerializeMap` types
|
|
124
|
+
*
|
|
125
|
+
* If the base is empty, returns the new map; otherwise merges both.
|
|
126
|
+
*/
|
|
127
|
+
type MergeMap<TBase extends SerializeMap, TNew extends SerializeMap> = TBase extends EmptyMap ? TNew : Flatten<TBase & TNew>;
|
|
128
|
+
/**
|
|
129
|
+
* Fluent builder for constructing a typed `Serializer` instance
|
|
130
|
+
*
|
|
131
|
+
* You start with `SerializeBuilder.new()`, then chain `.add(...)` for each entity type you want to support, and finally
|
|
132
|
+
* call `.build()` to get the serializer.
|
|
133
|
+
*/
|
|
134
|
+
export declare class SerializeBuilder<T extends SerializeMap> {
|
|
135
|
+
private readonly serializers;
|
|
136
|
+
private constructor();
|
|
137
|
+
/**
|
|
138
|
+
* Creates a new `SerializeBuilder` with no registered serializers
|
|
139
|
+
*/
|
|
140
|
+
static new(): SerializeBuilder<EmptyMap>;
|
|
141
|
+
/**
|
|
142
|
+
* Adds a serializer for a given resource `type`
|
|
143
|
+
*/
|
|
144
|
+
add<TType extends string, TEntity extends object, TResult = SerializeBuilder<MergeMap<T, {
|
|
145
|
+
[K in TType]: EntitySerializer<TEntity>;
|
|
146
|
+
}>>>(type: TType, serializer: EntitySerializer<TEntity>): TResult;
|
|
147
|
+
/**
|
|
148
|
+
* Finalizes the builder and returns a fully-typed JSON:API serializer
|
|
149
|
+
*
|
|
150
|
+
* The returned function can serialize a single entity or an iterable of entities using the registered serializers.
|
|
151
|
+
*/
|
|
152
|
+
build(): Serializer<T>;
|
|
153
|
+
}
|
|
154
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { serializeDocument } from "./internal.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fluent builder for constructing a typed `Serializer` instance
|
|
4
|
+
*
|
|
5
|
+
* You start with `SerializeBuilder.new()`, then chain `.add(...)` for each entity type you want to support, and finally
|
|
6
|
+
* call `.build()` to get the serializer.
|
|
7
|
+
*/
|
|
8
|
+
export class SerializeBuilder {
|
|
9
|
+
serializers;
|
|
10
|
+
constructor(serializers) {
|
|
11
|
+
this.serializers = serializers;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new `SerializeBuilder` with no registered serializers
|
|
15
|
+
*/
|
|
16
|
+
static new() {
|
|
17
|
+
return new SerializeBuilder({});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Adds a serializer for a given resource `type`
|
|
21
|
+
*/
|
|
22
|
+
add(type, serializer) {
|
|
23
|
+
return new SerializeBuilder({
|
|
24
|
+
...this.serializers,
|
|
25
|
+
[type]: serializer,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Finalizes the builder and returns a fully-typed JSON:API serializer
|
|
30
|
+
*
|
|
31
|
+
* The returned function can serialize a single entity or an iterable of entities using the registered serializers.
|
|
32
|
+
*/
|
|
33
|
+
build() {
|
|
34
|
+
return (type, entity, options) => {
|
|
35
|
+
return serializeDocument(this.serializers, type, entity, options);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jsonapi-serde/server",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Framework agnostic JSON:API serialization and deserialization",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Ben Scholzen 'DASPRiD'",
|
|
7
|
+
"license": "BSD-3-Clause",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"jsonapi",
|
|
10
|
+
"typescript",
|
|
11
|
+
"zod",
|
|
12
|
+
"serializer",
|
|
13
|
+
"deserializer",
|
|
14
|
+
"parser"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/dasprid/jsonapi-serde-js.git"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/**/*"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
"./common": {
|
|
25
|
+
"types": [
|
|
26
|
+
"./dist/common/index.d.ts",
|
|
27
|
+
"./src/common/index.ts"
|
|
28
|
+
],
|
|
29
|
+
"jsonapi-serde": "./src/common/index.ts",
|
|
30
|
+
"default": "./dist/common/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./http": {
|
|
33
|
+
"types": [
|
|
34
|
+
"./dist/http/index.d.ts",
|
|
35
|
+
"./src/http/index.ts"
|
|
36
|
+
],
|
|
37
|
+
"jsonapi-serde": "./src/http/index.ts",
|
|
38
|
+
"default": "./dist/http/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./request": {
|
|
41
|
+
"types": [
|
|
42
|
+
"./dist/request/index.d.ts",
|
|
43
|
+
"./src/request/index.ts"
|
|
44
|
+
],
|
|
45
|
+
"jsonapi-serde": "./src/request/index.ts",
|
|
46
|
+
"default": "./dist/request/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./response": {
|
|
49
|
+
"types": [
|
|
50
|
+
"./dist/response/index.d.ts",
|
|
51
|
+
"./src/response/index.ts"
|
|
52
|
+
],
|
|
53
|
+
"jsonapi-serde": "./src/response/index.ts",
|
|
54
|
+
"default": "./dist/response/index.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"zod": "^3.25.42"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"zod": "^3.25.42"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.build.json",
|
|
65
|
+
"test": "tsx --test --test-reporter=spec",
|
|
66
|
+
"typecheck": "tsc --noEmit",
|
|
67
|
+
"ci:test": "c8 --reporter=lcov pnpm test"
|
|
68
|
+
}
|
|
69
|
+
}
|