@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.
@@ -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
+ }