diff --git a/adapters/http.ts b/adapters/http.ts index 50d485a..e9daf1e 100644 --- a/adapters/http.ts +++ b/adapters/http.ts @@ -1,4 +1,5 @@ import type { RelayAdapter, RelayRequestInput, RelayResponse } from "../libraries/adapter.ts"; +import { RelayError, UnprocessableContentError } from "../libraries/errors.ts"; export class HttpAdapter implements RelayAdapter { #id: number = 0; @@ -6,10 +7,28 @@ export class HttpAdapter implements RelayAdapter { constructor(readonly url: string) {} async send({ method, params }: RelayRequestInput): Promise { - const res = await fetch(this.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method, params, id: this.#id++ }) }); - if (res.headers.get("content-type")?.includes("application/json") === false) { - throw new Error("Unexpected return type"); + const id = this.#id++; + const res = await fetch(this.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ relay: "1.0", method, params, id }), + }); + const contentType = res.headers.get("content-type"); + if (contentType !== "application/json") { + return { + relay: "1.0", + error: new UnprocessableContentError(`Invalid 'content-type' in header header, expected 'application/json', received '${contentType}'`), + id, + }; } - return res.json(); + const json = await res.json(); + if ("error" in json) { + return { + relay: "1.0", + error: RelayError.fromJSON(json.error), + id, + }; + } + return json; } } diff --git a/deno.json b/deno.json index 71adc30..a1dd375 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@valkyr/relay", - "version": "0.3.0", + "version": "0.3.1", "exports": { ".": "./mod.ts", "./http": "./adapters/http.ts" diff --git a/libraries/adapter.ts b/libraries/adapter.ts index c810390..f0db0f0 100644 --- a/libraries/adapter.ts +++ b/libraries/adapter.ts @@ -1,3 +1,5 @@ +import { RelayError } from "./errors.ts"; + export type RelayAdapter = { send(input: RelayRequestInput): Promise; }; @@ -9,12 +11,12 @@ export type RelayRequestInput = { export type RelayResponse = | { + relay: "1.0"; result: unknown; - id: string; + id: string | number; } | { - error: { - message: string; - }; - id: string; + relay: "1.0"; + error: RelayError; + id: string | number; }; diff --git a/libraries/api.ts b/libraries/api.ts index e8b87f3..0895f5b 100644 --- a/libraries/api.ts +++ b/libraries/api.ts @@ -2,6 +2,7 @@ import z from "zod"; import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts"; import { Procedure } from "./procedure.ts"; +import { RelayRequest, request } from "./request.ts"; export class RelayApi { /** @@ -21,6 +22,15 @@ export class RelayApi { } } + /** + * Takes a request candidate and parses its json body. + * + * @param candidate - Request candidate to parse. + */ + async parse(candidate: Request): Promise { + return request.parseAsync(await candidate.json()); + } + /** * Handle a incoming fetch request. * @@ -28,7 +38,7 @@ export class RelayApi { * @param params - Parameters provided with the method request. * @param id - Request id used for response identification. */ - async call(method: string, params: unknown, id: string): Promise { + async call({ method, params, id }: RelayRequest): Promise { const procedure = this.#index.get(method); if (procedure === undefined) { return toResponse(new NotFoundError(`Method '' does not exist`), id); @@ -112,10 +122,11 @@ export class RelayApi { * @param result - Result to send back as a Response. * @param id - Request id which can be used to identify the response. */ -function toResponse(result: object | RelayError | Response | void, id: string): Response { +export function toResponse(result: object | RelayError | Response | void, id: string | number): Response { if (result === undefined) { return new Response( JSON.stringify({ + relay: "1.0", result: null, id, }), @@ -133,6 +144,7 @@ function toResponse(result: object | RelayError | Response | void, id: string): if (result instanceof RelayError) { return new Response( JSON.stringify({ + relay: "1.0", error: result, id, }), @@ -146,6 +158,7 @@ function toResponse(result: object | RelayError | Response | void, id: string): } return new Response( JSON.stringify({ + relay: "1.0", result, id, }), diff --git a/libraries/errors.ts b/libraries/errors.ts index 2003d62..c51b145 100644 --- a/libraries/errors.ts +++ b/libraries/errors.ts @@ -1,17 +1,50 @@ -export abstract class RelayError extends Error { +export abstract class RelayError extends Error { constructor( message: string, readonly status: number, - readonly data?: D, + readonly data?: TData, ) { super(message); } - toJSON(): { - status: number; - message: string; - data: any; - } { + /** + * Converts a server delivered JSON error to its native instance. + * + * @param value - Error JSON. + */ + static fromJSON(value: RelayErrorJSON): RelayErrorType { + switch (value.status) { + case 400: + return new BadRequestError(value.message, value.data); + case 401: + return new UnauthorizedError(value.message, value.data); + case 403: + return new ForbiddenError(value.message, value.data); + case 404: + return new NotFoundError(value.message, value.data); + case 405: + return new MethodNotAllowedError(value.message, value.data); + case 406: + return new NotAcceptableError(value.message, value.data); + case 409: + return new ConflictError(value.message, value.data); + case 410: + return new GoneError(value.message, value.data); + case 415: + return new UnsupportedMediaTypeError(value.message, value.data); + case 422: + return new UnprocessableContentError(value.message, value.data); + case 503: + return new ServiceUnavailableError(value.message, value.data); + default: + return new InternalServerError(value.message, value.data); + } + } + + /** + * Convert error instance to a JSON object. + */ + toJSON(): RelayErrorJSON { return { status: this.status, message: this.message, @@ -20,7 +53,7 @@ export abstract class RelayError extends Error { } } -export class BadRequestError extends RelayError { +export class BadRequestError extends RelayError { /** * Instantiate a new BadRequestError. * @@ -30,12 +63,12 @@ export class BadRequestError extends RelayError { * * @param data - Optional data to send with the error. */ - constructor(message = "Bad Request", data?: D) { + constructor(message = "Bad Request", data?: TData) { super(message, 400, data); } } -export class UnauthorizedError extends RelayError { +export class UnauthorizedError extends RelayError { /** * Instantiate a new UnauthorizedError. * @@ -56,12 +89,12 @@ export class UnauthorizedError extends RelayError { * @param message - Optional message to send with the error. Default: "Unauthorized". * @param data - Optional data to send with the error. */ - constructor(message = "Unauthorized", data?: D) { + constructor(message = "Unauthorized", data?: TData) { super(message, 401, data); } } -export class ForbiddenError extends RelayError { +export class ForbiddenError extends RelayError { /** * Instantiate a new ForbiddenError. * @@ -77,12 +110,12 @@ export class ForbiddenError extends RelayError { * @param message - Optional message to send with the error. Default: "Forbidden". * @param data - Optional data to send with the error. */ - constructor(message = "Forbidden", data?: D) { + constructor(message = "Forbidden", data?: TData) { super(message, 403, data); } } -export class NotFoundError extends RelayError { +export class NotFoundError extends RelayError { /** * Instantiate a new NotFoundError. * @@ -99,12 +132,12 @@ export class NotFoundError extends RelayError { * @param message - Optional message to send with the error. Default: "Not Found". * @param data - Optional data to send with the error. */ - constructor(message = "Not Found", data?: D) { + constructor(message = "Not Found", data?: TData) { super(message, 404, data); } } -export class MethodNotAllowedError extends RelayError { +export class MethodNotAllowedError extends RelayError { /** * Instantiate a new MethodNotAllowedError. * @@ -116,12 +149,12 @@ export class MethodNotAllowedError extends RelayError { * @param message - Optional message to send with the error. Default: "Method Not Allowed". * @param data - Optional data to send with the error. */ - constructor(message = "Method Not Allowed", data?: D) { + constructor(message = "Method Not Allowed", data?: TData) { super(message, 405, data); } } -export class NotAcceptableError extends RelayError { +export class NotAcceptableError extends RelayError { /** * Instantiate a new NotAcceptableError. * @@ -133,12 +166,12 @@ export class NotAcceptableError extends RelayError { * @param message - Optional message to send with the error. Default: "Not Acceptable". * @param data - Optional data to send with the error. */ - constructor(message = "Not Acceptable", data?: D) { + constructor(message = "Not Acceptable", data?: TData) { super(message, 406, data); } } -export class ConflictError extends RelayError { +export class ConflictError extends RelayError { /** * Instantiate a new ConflictError. * @@ -154,12 +187,12 @@ export class ConflictError extends RelayError { * @param message - Optional message to send with the error. Default: "Conflict". * @param data - Optional data to send with the error. */ - constructor(message = "Conflict", data?: D) { + constructor(message = "Conflict", data?: TData) { super(message, 409, data); } } -export class GoneError extends RelayError { +export class GoneError extends RelayError { /** * Instantiate a new GoneError. * @@ -177,12 +210,12 @@ export class GoneError extends RelayError { * @param message - Optional message to send with the error. Default: "Gone". * @param data - Optional data to send with the error. */ - constructor(message = "Gone", data?: D) { + constructor(message = "Gone", data?: TData) { super(message, 410, data); } } -export class UnsupportedMediaTypeError extends RelayError { +export class UnsupportedMediaTypeError extends RelayError { /** * Instantiate a new UnsupportedMediaTypeError. * @@ -194,12 +227,12 @@ export class UnsupportedMediaTypeError extends RelayError { * @param message - Optional message to send with the error. Default: "Unsupported Media Type". * @param data - Optional data to send with the error. */ - constructor(message = "Unsupported Media Type", data?: D) { + constructor(message = "Unsupported Media Type", data?: TData) { super(message, 415, data); } } -export class UnprocessableContentError extends RelayError { +export class UnprocessableContentError extends RelayError { /** * Instantiate a new UnprocessableContentError. * @@ -216,12 +249,12 @@ export class UnprocessableContentError extends RelayError { * @param message - Optional message to send with the error. Default: "Unprocessable Content". * @param data - Optional data to send with the error. */ - constructor(message = "Unprocessable Content", data?: D) { + constructor(message = "Unprocessable Content", data?: TData) { super(message, 422, data); } } -export class InternalServerError extends RelayError { +export class InternalServerError extends RelayError { /** * Instantiate a new InternalServerError. * @@ -239,12 +272,12 @@ export class InternalServerError extends RelayError { * @param message - Optional message to send with the error. Default: "Internal Server Error". * @param data - Optional data to send with the error. */ - constructor(message = "Internal Server Error", data?: D) { + constructor(message = "Internal Server Error", data?: TData) { super(message, 500, data); } } -export class ServiceUnavailableError extends RelayError { +export class ServiceUnavailableError extends RelayError { /** * Instantiate a new ServiceUnavailableError. * @@ -259,7 +292,27 @@ export class ServiceUnavailableError extends RelayError { * @param message - Optional message to send with the error. Default: "Service Unavailable". * @param data - Optional data to send with the error. */ - constructor(message = "Service Unavailable", data?: D) { + constructor(message = "Service Unavailable", data?: TData) { super(message, 503, data); } } + +export type RelayErrorJSON = { + status: number; + message: string; + data: any; +}; + +export type RelayErrorType = + | BadRequestError + | UnauthorizedError + | ForbiddenError + | NotFoundError + | MethodNotAllowedError + | NotAcceptableError + | ConflictError + | GoneError + | UnsupportedMediaTypeError + | UnprocessableContentError + | ServiceUnavailableError + | InternalServerError; diff --git a/libraries/request.ts b/libraries/request.ts new file mode 100644 index 0000000..d9ff296 --- /dev/null +++ b/libraries/request.ts @@ -0,0 +1,10 @@ +import z from "zod"; + +export const request = z.object({ + relay: z.literal("1.0"), + method: z.string(), + params: z.unknown(), + id: z.string().or(z.number()), +}); + +export type RelayRequest = z.infer; diff --git a/mod.ts b/mod.ts index f3e91f4..e0bb905 100644 --- a/mod.ts +++ b/mod.ts @@ -4,3 +4,4 @@ export * from "./libraries/api.ts"; export * from "./libraries/errors.ts"; export * from "./libraries/procedure.ts"; export * from "./libraries/relay.ts"; +export * from "./libraries/request.ts"; diff --git a/tests/procedure.test.ts b/tests/procedure.test.ts index bf9fd8f..af0dfdb 100644 --- a/tests/procedure.test.ts +++ b/tests/procedure.test.ts @@ -19,8 +19,7 @@ describe("Procedure", () => { }, }, async (request) => { - const { method, params, id } = await request.json(); - return api.call(method, params, id); + return api.call(await api.parse(request)); }, ); client = relay.client({