UNPKG

ketting

Version:

Opinionated HATEOAS / Rest client.

215 lines (175 loc) 5.11 kB
import { State } from './state/index.js'; import qs from 'query-string'; import Client from './client.js'; import { Field } from './field.js'; import Resource from './resource.js'; export interface ActionInfo { /** * What url to post the form to. */ uri: string; /** * Action name. * * Some formats call this the 'rel' */ name: string | null; /** * Form title. * * Should be human-friendly. */ title?: string; /** * The HTTP method to use */ method: string; /** * The contentType to use for the form submission */ contentType: string; /** * Returns the list of fields associated to an action */ fields: Field[]; } /** * An action represents a hypermedia form submission or action. */ export interface Action<T extends Record<string, any> = Record<string, any>> extends ActionInfo { /** * Execute the action or submit the form. */ submit(formData: T): Promise<State>; /** * Return a field by name. */ field(name: string): Field | undefined; /** * Execute the action or submit the form, then return the next resource. * * If a server responds with a 201 Status code and a Location header, * it will automatically return the newly created resource. * * If the server responded with a 204 or 205, this function will return * `this`. */ submitFollow(formData: T): Promise<Resource>; } export class SimpleAction<TFormData extends Record<string, any>> implements Action { /** * What url to post the form to. */ uri!: string; /** * Action name. * * Some formats call this the 'rel' */ name!: string | null; /** * Form title. * * Should be human-friendly. */ title!: string; /** * The HTTP method to use */ method!: string; /** * The contentType to use for the form submission */ contentType!: string; /** * Returns the list of fields associated to an action */ fields!: Field[]; /** * Reference to client */ client: Client; constructor(client: Client, formInfo: ActionInfo) { this.client = client; for(const [k, v] of Object.entries(formInfo)) { this[k as keyof ActionInfo] = v; } } /** * Execute the action or submit the form. */ async submit(formData: TFormData): Promise<State<any>> { const uri = new URL(this.uri); const newFormData = this.validateForm(formData); if (this.method === 'GET') { uri.search = qs.stringify(newFormData); const resource = this.client.go(uri.toString()); return resource.get(); } const response = await this.fetchOrThrowWithBody(uri, newFormData); const state = this.client.getStateForResponse(uri.toString(), response); return state; } async submitFollow(formData: TFormData): Promise<Resource> { const uri = new URL(this.uri); const newFormData = this.validateForm(formData); if (this.method === 'GET') { uri.search = qs.stringify(newFormData); return this.client.go(uri.toString()); } const response = await this.fetchOrThrowWithBody(uri, newFormData); switch (response.status) { case 201: if (response.headers.has('location')) { return this.client.go(response.headers.get('location')!); } throw new Error('Could not follow after a 201 request, because the server did not reply with a Location header. If you sent a Location header, check if your service is returning "Access-Control-Expose-Headers: Location".'); case 204 : case 205 : return this.client.go(uri.toString()); default: throw new Error('Did not receive a 201, 204 or 205 status code so we could not follow to the next resource'); } } private validateForm(formData: TFormData): TFormData { const newFormData: TFormData = { ...formData }; for (const field of this.fields) { if (!(field.name in formData)) { if (field.value) { // We don't have perfect types for fields vs. FormData and how they // related, so 'any' is needed here. (newFormData as any)[field.name] = field.value; } else if (field.required) { throw new Error(`The ${field.name} field is required in this form`); } } } return newFormData; } private fetchOrThrowWithBody(uri: URL, formData: TFormData): Promise<Response> { let body; switch (this.contentType) { case 'application/x-www-form-urlencoded' : body = qs.stringify(formData); break; case 'application/json': body = JSON.stringify(formData); break; default : throw new Error(`Serializing mimetype ${this.contentType} is not yet supported in actions`); } return this.client.fetcher.fetchOrThrow(uri.toString(), { method: this.method, body, headers: { 'Content-Type': this.contentType } }); } field(name: string): Field | undefined { return this.fields.find(field => field.name === name); } } export class ActionNotFound extends Error {}