From 42d90d388401d479d75747a3c64812df3f00ea0c Mon Sep 17 00:00:00 2001 From: Lars Vierbergen Date: Thu, 21 Mar 2024 15:23:00 +0100 Subject: [PATCH] Add hal-forms codecs --- packages/hal-forms/README.md | 28 ++++ packages/hal-forms/__tests__/codecs.ts | 136 ++++++++++++++++++ packages/hal-forms/package.json | 10 ++ packages/hal-forms/src/codecs/api.ts | 89 ++++++++++++ packages/hal-forms/src/codecs/encoders/api.ts | 21 +++ .../hal-forms/src/codecs/encoders/index.ts | 2 + .../hal-forms/src/codecs/encoders/json.ts | 89 ++++++++++++ packages/hal-forms/src/codecs/errors.ts | 16 +++ packages/hal-forms/src/codecs/impl.ts | 75 ++++++++++ packages/hal-forms/src/codecs/index.ts | 13 ++ 10 files changed, 479 insertions(+) create mode 100644 packages/hal-forms/__tests__/codecs.ts create mode 100644 packages/hal-forms/src/codecs/api.ts create mode 100644 packages/hal-forms/src/codecs/encoders/api.ts create mode 100644 packages/hal-forms/src/codecs/encoders/index.ts create mode 100644 packages/hal-forms/src/codecs/encoders/json.ts create mode 100644 packages/hal-forms/src/codecs/errors.ts create mode 100644 packages/hal-forms/src/codecs/impl.ts create mode 100644 packages/hal-forms/src/codecs/index.ts diff --git a/packages/hal-forms/README.md b/packages/hal-forms/README.md index bb3856f..205f86b 100644 --- a/packages/hal-forms/README.md +++ b/packages/hal-forms/README.md @@ -77,6 +77,34 @@ templateValues.values.forEach(value => { +### Encoding HAL-FORMS into requests + +After collecting user input, you probably want to send a request to the backend to submit the values entered in the form. + +The `@contentgrid/hal-forms/codecs` sub-package encodes the values according to the content-type required by the HAL-FORMS template. + +
+ +Code example for encoding form values with codecs + +```typescript +import codecs from "@contentgrid/hal-forms/codecs"; + +const request = codecs.requireCodecFor(template) // template is a HalFormsTemplate + .encode(templateValues); // templateValues is a HalFormValues + +// The HAL-FORMS template method, target, contentType and values are encoded in request + +// Put some additional headers on your request here +request.headers.set('Authorization', 'Basic ....'); + +// Perform the HTTP request +const response = await fetch(request); +console.log(response); +``` + +
+ ### Shapes The `@contentgrid/hal-forms/shape` sub-package provides POJO (plain old javascript object) types that can be used to represent the raw HAL-FORMS JSON data. diff --git a/packages/hal-forms/__tests__/codecs.ts b/packages/hal-forms/__tests__/codecs.ts new file mode 100644 index 0000000..9a004d1 --- /dev/null +++ b/packages/hal-forms/__tests__/codecs.ts @@ -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 + } + }) + }); + +}) diff --git a/packages/hal-forms/package.json b/packages/hal-forms/package.json index 6978c4f..3c0477c 100644 --- a/packages/hal-forms/package.json +++ b/packages/hal-forms/package.json @@ -26,6 +26,16 @@ "types": "./build/values/index.d.ts", "import": "./build/values/index.mjs", "default": "./build/values/index.js" + }, + "./codecs": { + "types": "./build/codecs/index.d.ts", + "import": "./build/codecs/index.mjs", + "default": "./build/codecs/index.js" + }, + "./codecs/encoders": { + "types": "./build/codecs/encoders/index.d.ts", + "import": "./build/codecs/encoders/index.mjs", + "default": "./build/codecs/encoders/index.js" } }, "license": "MIT", diff --git a/packages/hal-forms/src/codecs/api.ts b/packages/hal-forms/src/codecs/api.ts new file mode 100644 index 0000000..5990f6e --- /dev/null +++ b/packages/hal-forms/src/codecs/api.ts @@ -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 { + /** + * 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 +} + +/** + * 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(template: HalFormsTemplate>): HalFormsCodec | 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(template: HalFormsTemplate>): HalFormsCodec; +} + +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; +} diff --git a/packages/hal-forms/src/codecs/encoders/api.ts b/packages/hal-forms/src/codecs/encoders/api.ts new file mode 100644 index 0000000..4a8b24f --- /dev/null +++ b/packages/hal-forms/src/codecs/encoders/api.ts @@ -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(template: HalFormsTemplate>, values: readonly AnyHalFormValue[]): TypedRequest; +} diff --git a/packages/hal-forms/src/codecs/encoders/index.ts b/packages/hal-forms/src/codecs/encoders/index.ts new file mode 100644 index 0000000..5bf4bdd --- /dev/null +++ b/packages/hal-forms/src/codecs/encoders/index.ts @@ -0,0 +1,2 @@ +export type * from "./api"; +export { json, nestedJson } from "./json" diff --git a/packages/hal-forms/src/codecs/encoders/json.ts b/packages/hal-forms/src/codecs/encoders/json.ts new file mode 100644 index 0000000..3311e34 --- /dev/null +++ b/packages/hal-forms/src/codecs/encoders/json.ts @@ -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(template: HalFormsTemplate>, values: readonly AnyHalFormValue[]): TypedRequest { + const jsonObject: Partial = {}; + + 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(object: Partial, 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(object: Partial, 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; + } + +} diff --git a/packages/hal-forms/src/codecs/errors.ts b/packages/hal-forms/src/codecs/errors.ts new file mode 100644 index 0000000..5a34a25 --- /dev/null +++ b/packages/hal-forms/src/codecs/errors.ts @@ -0,0 +1,16 @@ +import { HalFormsTemplateError, HalFormsTemplate } from ".."; + + +/** + * Exception thrown when no codec is available for a HAL-FORMS template + */ +export class HalFormsCodecNotAvailableError extends HalFormsTemplateError { + // @internal This exception should only be constructed by this package itself + public constructor( + template: HalFormsTemplate + ) { + super(template.name, `no encoder available for content type "${template.contentType}"`); + Object.setPrototypeOf(this, new.target.prototype); + this.name = HalFormsCodecNotAvailableError.name; + } +} diff --git a/packages/hal-forms/src/codecs/impl.ts b/packages/hal-forms/src/codecs/impl.ts new file mode 100644 index 0000000..03fdd0d --- /dev/null +++ b/packages/hal-forms/src/codecs/impl.ts @@ -0,0 +1,75 @@ +import { TypedRequest, TypedRequestSpec } from "@contentgrid/typed-fetch"; +import { HalFormsTemplate } from ".."; +import { HalFormsCodec, HalFormsCodecs, HalFormsCodecsBuilder } from "./api"; +import { HalFormsEncoder } from "./encoders/api"; +import { AnyHalFormValue } from "../values/api"; +import { HalFormsCodecNotAvailableError } from "./errors"; + +abstract class AbstractHalFormsCodecs implements HalFormsCodecs { + abstract findCodecFor(template: HalFormsTemplate>): HalFormsCodec | null; + + public requireCodecFor(template: HalFormsTemplate>): HalFormsCodec { + const codec = this.findCodecFor(template); + if(codec === null) { + throw new HalFormsCodecNotAvailableError(template); + } + return codec; + } +} + +class SingleEncoderHalFormsCodecs extends AbstractHalFormsCodecs { + public constructor( + private readonly contentType: string, + private readonly encoder: HalFormsEncoder + ) { + super(); + } + + public findCodecFor(template: HalFormsTemplate>): HalFormsCodec | null { + if(template.contentType === this.contentType) { + return new HalFormsCodecImpl(template, this.encoder); + } + return null; + } +} + +class HalFormsCodecImpl implements HalFormsCodec { + public constructor( + private readonly template: HalFormsTemplate>, + private readonly encoder: HalFormsEncoder + ) { + + } + + public encode(values: readonly AnyHalFormValue[]): TypedRequest { + return this.encoder.encode(this.template, values); + } + +} + +export default class HalFormsCodecsBuilderImpl extends AbstractHalFormsCodecs implements HalFormsCodecs, HalFormsCodecsBuilder { + private readonly codecs: HalFormsCodecs[] = []; + + public registerEncoder(contentType: string, encoder: HalFormsEncoder): this { + return this.registerCodecs(new SingleEncoderHalFormsCodecs(contentType, encoder)); + } + + public registerCodecs(codecs: HalFormsCodecs): this { + this.codecs.push(codecs); + return this; + } + + public build(): HalFormsCodecs { + return this; + } + + public findCodecFor(template: HalFormsTemplate>): HalFormsCodec |null{ + for(const codecs of this.codecs) { + const codec = codecs.findCodecFor(template); + if(codec) { + return codec; + } + } + return null; + } +} diff --git a/packages/hal-forms/src/codecs/index.ts b/packages/hal-forms/src/codecs/index.ts new file mode 100644 index 0000000..2088a99 --- /dev/null +++ b/packages/hal-forms/src/codecs/index.ts @@ -0,0 +1,13 @@ +export * from "./api"; +export * from "./errors"; +export * as Encoders from "./encoders"; + +import { HalFormsCodecs } from "./api"; +import { nestedJson } from "./encoders"; + +/** + * Default HAL-FORMS codecs + */ +export default HalFormsCodecs.builder() + .registerEncoder("application/json", nestedJson()) + .build();