-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
0e23ee1
commit 3d555db
Showing
10 changed files
with
479 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import { describe, expect, test } from "@jest/globals"; | ||
import buildTemplate from "../src/builder"; | ||
import { default as codecs, Encoders, HalFormsCodecNotAvailableError, HalFormsCodecs } from "../src/codecs"; | ||
import { createValues } from "../src/values"; | ||
|
||
const form = buildTemplate("POST", "http://localhost/invoices") | ||
.withContentType("application/json") | ||
.addProperty("created_at", b => b | ||
.withType("datetime") | ||
.withRequired(true) | ||
) | ||
.addProperty("total.net", b => b | ||
.withType("number") | ||
) | ||
.addProperty("total.vat", b => b | ||
.withType("number") | ||
) | ||
.addProperty("senders", b => b.withType("url") | ||
.withOptions(b => b.withMinItems(0)) | ||
) | ||
.addProperty("name", b => b.withValue("Jefke")); | ||
|
||
describe("HalFormsCodecs", () => { | ||
|
||
describe("#findCodecFor()", () => { | ||
test("finds a codec", () => { | ||
const c = codecs.findCodecFor(form); | ||
expect(c).not.toBeNull(); | ||
}) | ||
|
||
test("does not find a codec", () => { | ||
const empty = HalFormsCodecs.builder() | ||
.registerEncoder("example/json", Encoders.json()) | ||
.build(); | ||
const c = empty.findCodecFor(form); | ||
expect(c).toBeNull(); | ||
}) | ||
}); | ||
|
||
describe("#requireCodecFor()", () => { | ||
|
||
test("requires a codec", () => { | ||
const c = codecs.requireCodecFor(form); | ||
expect(c).not.toBeNull(); | ||
}) | ||
|
||
test("does not find a required codec", () => { | ||
const empty = HalFormsCodecs.builder().build(); | ||
expect(() => empty.requireCodecFor(form)) | ||
.toThrowError(new HalFormsCodecNotAvailableError(form)); | ||
}) | ||
|
||
}) | ||
}) | ||
|
||
describe("Encoders.json()", () => { | ||
const values = createValues(form); | ||
const codecs = HalFormsCodecs.builder() | ||
.registerEncoder("application/json", Encoders.json()) | ||
.build(); | ||
|
||
test("Encodes default values", () => { | ||
const encoded = codecs.requireCodecFor(form).encode(values.values); | ||
|
||
expect(encoded).toBeInstanceOf(Request); | ||
|
||
expect(encoded.json()) | ||
.resolves | ||
.toEqual({ | ||
"name": "Jefke" | ||
}); | ||
}) | ||
|
||
test("Encodes nested values as flat JSON", () => { | ||
|
||
const encoded = codecs.requireCodecFor(form).encode( | ||
values | ||
.withValue("total.net", 123) | ||
.withValue("total.vat", 456) | ||
.values | ||
); | ||
|
||
expect(encoded).toBeInstanceOf(Request); | ||
|
||
expect(encoded.json()) | ||
.resolves | ||
.toEqual({ | ||
"name": "Jefke", | ||
"total.net": 123, | ||
"total.vat": 456 | ||
}) | ||
}); | ||
|
||
}) | ||
|
||
describe("Encoders.nestedJson()", () => { | ||
const values = createValues(form); | ||
const codecs = HalFormsCodecs.builder() | ||
.registerEncoder("application/json", Encoders.nestedJson()) | ||
.build(); | ||
|
||
test("Encodes default values", () => { | ||
const encoded = codecs.requireCodecFor(form).encode(values.values); | ||
|
||
expect(encoded).toBeInstanceOf(Request); | ||
|
||
expect(encoded.json()) | ||
.resolves | ||
.toEqual({ | ||
"name": "Jefke" | ||
}); | ||
}) | ||
|
||
test("Encodes nested values as flat JSON", () => { | ||
|
||
const encoded = codecs.requireCodecFor(form).encode( | ||
values | ||
.withValue("total.net", 123) | ||
.withValue("total.vat", 456) | ||
.values | ||
); | ||
|
||
expect(encoded).toBeInstanceOf(Request); | ||
|
||
expect(encoded.json()) | ||
.resolves | ||
.toEqual({ | ||
"name": "Jefke", | ||
"total": { | ||
"net": 123, | ||
"vat": 456 | ||
} | ||
}) | ||
}); | ||
|
||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { TypedRequest, TypedRequestSpec } from "@contentgrid/typed-fetch"; | ||
import { HalFormsTemplate } from "../api"; | ||
import { AnyHalFormValue } from "../values"; | ||
import { HalFormsEncoder } from "./encoders"; | ||
import HalFormsCodecsImpl from "./impl"; | ||
|
||
/** | ||
* HAL-FORMS codec | ||
* | ||
* A codec encodes HAL-FORMS values into a {@link TypedRequest} for sending with {@link fetch} | ||
*/ | ||
export interface HalFormsCodec<T, R> { | ||
/** | ||
* Encode HAL-FORMS values into a request | ||
* @param values - The HAL-FORMS values to encode | ||
* @returns A {@link TypedRequest} that can be sent with {@link fetch} | ||
*/ | ||
encode(values: readonly AnyHalFormValue[]): TypedRequest<T, R> | ||
} | ||
|
||
/** | ||
* Lookup for {@link HalFormsCodec}s | ||
* | ||
* @see {@link HalFormsCodecs.builder} to build an instance | ||
*/ | ||
export interface HalFormsCodecs { | ||
/** | ||
* Look for a codec for a HAL-FORMS template | ||
* | ||
* @see {@link HalFormsCodecs#requireCodecFor} for the variant that throws an error when no codec is available | ||
* | ||
* @param template - The HAL-FORMS template to look up a codec for | ||
* @return A codec if one is available, or null if no codec is available | ||
*/ | ||
findCodecFor<T, R>(template: HalFormsTemplate<TypedRequestSpec<T, R>>): HalFormsCodec<T, R> | null; | ||
|
||
/** | ||
* Require a codec for a HAL-FORMS template | ||
* | ||
* @see {@link HalFormsCodecs#requireCodecFor} for the variant that throws an error when no codec is available | ||
* | ||
* @param template - The HAL-FORMS template to look up a codec for | ||
* | ||
* @throws {@link ./errors#HalFormsCodecNotAvailableError} when no codec is available | ||
* | ||
* @return A codec | ||
*/ | ||
requireCodecFor<T, R>(template: HalFormsTemplate<TypedRequestSpec<T, R>>): HalFormsCodec<T, R>; | ||
} | ||
|
||
export namespace HalFormsCodecs { | ||
|
||
/** | ||
* @returns Create a builder for {@link HalFormsCodecs} | ||
*/ | ||
export function builder(): HalFormsCodecsBuilder { | ||
return new HalFormsCodecsImpl(); | ||
} | ||
} | ||
|
||
/** | ||
* Builder for {@link HalFormsCodecs} | ||
*/ | ||
export interface HalFormsCodecsBuilder { | ||
|
||
/** | ||
* Registers an encoder for a content type. | ||
* | ||
* All HAL-FORMS templates with a certain `contentType` will be encoded using this encoder. | ||
* | ||
* @param contentType - The content-type to register the encoder for | ||
* @param encoder - The encoder to use for the content-type | ||
*/ | ||
registerEncoder(contentType: string, encoder: HalFormsEncoder): this; | ||
|
||
/** | ||
* Registers all codecs from a {@link HalFormsCodecs} | ||
* | ||
* All available codecs will be used | ||
* | ||
* @param codecs - Codecs to register | ||
*/ | ||
registerCodecs(codecs: HalFormsCodecs): this; | ||
|
||
/** | ||
* Finish building and create the {@link HalFormsCodecs} | ||
*/ | ||
build(): HalFormsCodecs; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { TypedRequest, TypedRequestSpec } from "@contentgrid/typed-fetch"; | ||
import { HalFormsTemplate } from "../../api"; | ||
import { AnyHalFormValue } from "../../values"; | ||
|
||
/** | ||
* HAL-FORMS template value encoder | ||
* | ||
* This interface is only for implementing custom encoders. | ||
* | ||
* @see {@link ../api#HalFormsCodec} for encoding values | ||
*/ | ||
export interface HalFormsEncoder { | ||
|
||
/** | ||
* Encode HAL-FORMS values into a request | ||
* | ||
* @param template - HAL-FORMS template | ||
* @param values - The HAL-FORMS values to encode | ||
*/ | ||
encode<T, R>(template: HalFormsTemplate<TypedRequestSpec<T, R>>, values: readonly AnyHalFormValue[]): TypedRequest<T, R>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export type * from "./api"; | ||
export { json, nestedJson } from "./json" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
import { Representation, TypedRequest, TypedRequestSpec, createRequest } from "@contentgrid/typed-fetch"; | ||
import { HalFormsProperty, HalFormsTemplate } from "../../api"; | ||
import { AnyHalFormValue } from "../../values/api"; | ||
import { HalFormsEncoder } from "./api"; | ||
|
||
/** | ||
* Encodes HAL-FORMS values as a JSON object | ||
* | ||
* The JSON object is created as a simple object mapping a HAL-FORMS property name to its value. | ||
* Nested objects are not supported. | ||
*/ | ||
export function json(): HalFormsEncoder { | ||
return new JsonHalFormsEncoder(null); | ||
} | ||
|
||
/** | ||
* Encodes HAL-FORMS values as a nested JSON object | ||
* | ||
* The JSON object is created as an object mapping a HAL-FORMS property name to their values. | ||
* Nested objects are supported; The separator character accesses nested JSON objects. | ||
* | ||
* e.g. A HAL-FORM with properties `user.name`, `user.email` and `address` will be serialized as | ||
* ``` | ||
* { | ||
* "user": { | ||
* "name": ..., | ||
* "email": ... | ||
* }, | ||
* "address": ... | ||
* } | ||
* ``` | ||
* | ||
* | ||
* @param separatorCharacter - Separator character; must be exactly one character long | ||
*/ | ||
export function nestedJson(separatorCharacter: string = "."): HalFormsEncoder { | ||
if (separatorCharacter.length !== 1) { | ||
throw new Error(`Nested property separator must be null or a string of length 1; got "${separatorCharacter}"`) | ||
} | ||
return new JsonHalFormsEncoder(separatorCharacter); | ||
} | ||
|
||
class JsonHalFormsEncoder implements HalFormsEncoder { | ||
|
||
public constructor(private nestedObjectSeparator: string | null) { | ||
} | ||
|
||
public encode<T, R>(template: HalFormsTemplate<TypedRequestSpec<T, R>>, values: readonly AnyHalFormValue[]): TypedRequest<T, R> { | ||
const jsonObject: Partial<T> = {}; | ||
|
||
values.forEach((value) => { | ||
this.appendToJsonObject(jsonObject, value.property.name, value.value, value.property) | ||
}) | ||
|
||
return createRequest(template.request, { | ||
body: Representation.json(jsonObject as T) | ||
}); | ||
} | ||
|
||
private appendToJsonObject<T>(object: Partial<T>, key: string, value: AnyHalFormValue["value"], property: HalFormsProperty) { | ||
if(value === undefined) { | ||
// undefined never gets serialized anyways, don't write it to the object at all, | ||
// so we don't create empty nested objects when all values are unset | ||
return; | ||
} | ||
if(this.nestedObjectSeparator !== null) { | ||
const [firstPart, nextParts] = key.split(this.nestedObjectSeparator, 2); | ||
if(nextParts === undefined) { | ||
this.safeWriteProperty(object, firstPart as keyof T, value, property); | ||
return; | ||
} | ||
|
||
if(!(firstPart as keyof T in object)) { | ||
this.safeWriteProperty(object, firstPart as keyof T, {}, property); | ||
} | ||
this.appendToJsonObject(object[firstPart as keyof T] as any, nextParts, value, property); | ||
} else { | ||
this.safeWriteProperty(object, key as keyof T, value, property); | ||
} | ||
} | ||
|
||
private safeWriteProperty<T>(object: Partial<T>, key: keyof T, value: any, property: HalFormsProperty) { | ||
if(key in object) { | ||
throw new Error(`Can not write multiple values for property ${property.name}`); | ||
} | ||
object[key] = value; | ||
} | ||
|
||
} |
Oops, something went wrong.