Skip to content

Commit

Permalink
Add hal-forms codecs
Browse files Browse the repository at this point in the history
  • Loading branch information
vierbergenlars committed Mar 21, 2024
1 parent be5e596 commit 42d90d3
Show file tree
Hide file tree
Showing 10 changed files with 479 additions and 0 deletions.
28 changes: 28 additions & 0 deletions packages/hal-forms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ templateValues.values.forEach(value => {

</details>

### 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.

<details>

<summary>Code example for encoding form values with codecs</summary>

```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);
```

</details>

### 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.
Expand Down
136 changes: 136 additions & 0 deletions packages/hal-forms/__tests__/codecs.ts
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
}
})
});

})
10 changes: 10 additions & 0 deletions packages/hal-forms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions packages/hal-forms/src/codecs/api.ts
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;
}
21 changes: 21 additions & 0 deletions packages/hal-forms/src/codecs/encoders/api.ts
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>;
}
2 changes: 2 additions & 0 deletions packages/hal-forms/src/codecs/encoders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type * from "./api";
export { json, nestedJson } from "./json"
89 changes: 89 additions & 0 deletions packages/hal-forms/src/codecs/encoders/json.ts
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;
}

}
Loading

0 comments on commit 42d90d3

Please sign in to comment.