Refactor Hono API handler, fix missing headers
This commit is contained in:
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"js/ts.preferences.autoImportFileExcludePatterns": [
|
||||
"@radix-ui",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
import { registerEncoder } from "cbor2/encoder";
|
||||
import * as Api from "common/Api";
|
||||
import * as Body from "common/Body";
|
||||
import * as Cbor from "common/Cbor";
|
||||
import * as Client from "common/Client";
|
||||
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
|
||||
import { Cause, Console, Effect, Either, HashMap, Inspectable, Option, ParseResult, pipe, Schema } from "effect";
|
||||
import type { HonoRequest } from "hono";
|
||||
import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status";
|
||||
|
||||
registerEncoder(Buffer, (buffer) => [NaN, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)]);
|
||||
|
||||
export type ResponseData<Status extends StatusCode> =
|
||||
| EmptyResponseData<Status>
|
||||
| (Status extends ContentfulStatusCode ? BodyResponseData<Status> : never)
|
||||
;
|
||||
|
||||
export namespace ResponseData {
|
||||
export type Any = ResponseData<StatusCode>;
|
||||
}
|
||||
|
||||
export interface EmptyResponseData<Status extends StatusCode> {
|
||||
readonly body: null;
|
||||
readonly status: Status;
|
||||
}
|
||||
|
||||
export const emptyResponse = <Status extends StatusCode>(status: Status): EmptyResponseData<Status> => Object.freeze<EmptyResponseData<Status>>({
|
||||
body: null,
|
||||
status,
|
||||
});
|
||||
|
||||
export interface BodyResponseData<Status extends ContentfulStatusCode> {
|
||||
readonly body: Uint8Array<ArrayBuffer>;
|
||||
readonly status: Status;
|
||||
}
|
||||
|
||||
export const bodyResponse = <Status extends ContentfulStatusCode>(status: Status) => (body: Uint8Array<ArrayBuffer>): BodyResponseData<Status> => Object.freeze<BodyResponseData<Status>>({
|
||||
body,
|
||||
status,
|
||||
});
|
||||
|
||||
/* NOTE We shouldn't need to extract this to a separate type, but if we don't do
|
||||
* it the TypeScript parser in VS Code sort of blows up and the syntax colors
|
||||
* turn wrong.
|
||||
*/
|
||||
type Return<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<Response, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
|
||||
type Return<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<ResponseData.Any, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
|
||||
|
||||
const catchToResponse = Effect.catchAll((error: unknown) => pipe(
|
||||
const catchToResponseData = Effect.catchAll((error: unknown) => pipe(
|
||||
error,
|
||||
Inspectable.toJSON,
|
||||
Cbor.encode,
|
||||
Effect.map(Client.cborBody),
|
||||
Effect.matchEffect({
|
||||
onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })),
|
||||
onFailure: () => Effect.fail(new Response(null, { status: 500 })),
|
||||
onSuccess: (body) => Effect.fail<ResponseData.Any>(bodyResponse(500)(body)),
|
||||
onFailure: () => Effect.fail<ResponseData.Any>(RESPONSE_DATA_UNRECOVERABLE_ERROR),
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -32,26 +61,26 @@ export const implement = <
|
||||
impl: Impl,
|
||||
): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
|
||||
return (honoRequest) => {
|
||||
/* Force both return types to be `Response`. We can use the error route
|
||||
* for it's short-circuit capabilities.
|
||||
/* Force both return types to be `ResponseData`. We can use the error
|
||||
* route for it's short-circuit capabilities.
|
||||
*/
|
||||
const effect: Effect.Effect<Response, Response, any> = Effect.gen(function* () {
|
||||
const effect: Effect.Effect<ResponseData.Any, ResponseData.Any, any> = Effect.gen(function* () {
|
||||
const key = honoRequest.param("key");
|
||||
const maybeApi = HashMap.get(bundle.map, key);
|
||||
if (Option.isNone(maybeApi)) {
|
||||
return RESPONSE_API_NOT_FOUND;
|
||||
return RESPONSE_DATA_API_NOT_FOUND;
|
||||
}
|
||||
const { value: api } = maybeApi;
|
||||
|
||||
const fn = impl[key];
|
||||
if (fn === undefined) {
|
||||
return RESPONSE_API_NOT_IMPLEMENTED;
|
||||
return RESPONSE_DATA_API_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
const request = yield* pipe(
|
||||
honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> },
|
||||
Client.decodeBody(api.request),
|
||||
catchToResponse,
|
||||
decodeBody(api.request),
|
||||
catchToResponseData,
|
||||
);
|
||||
|
||||
const result = yield* pipe(
|
||||
@@ -61,51 +90,72 @@ export const implement = <
|
||||
Effect.either,
|
||||
);
|
||||
|
||||
const { status, response } = pipe(
|
||||
const response = yield* pipe(
|
||||
result,
|
||||
Either.match({
|
||||
onLeft: (cause) => pipe(
|
||||
cause,
|
||||
Cause.failureOrCause,
|
||||
Either.match({
|
||||
onLeft: (error) => ({
|
||||
status: 400,
|
||||
response: pipe(
|
||||
error,
|
||||
Client.encodeBody(api.error),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
onRight: (die) => ({
|
||||
status: 500,
|
||||
response: pipe(
|
||||
die,
|
||||
Inspectable.toJSON,
|
||||
Cbor.encode,
|
||||
Effect.map(Client.cborBody),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
onLeft: (body) => pipe(
|
||||
body,
|
||||
encodeBody(api.error, 400),
|
||||
catchToResponseData,
|
||||
),
|
||||
onRight: (die) => pipe(
|
||||
die,
|
||||
Inspectable.toJSON,
|
||||
Cbor.encode,
|
||||
Effect.map(bodyResponse(500)),
|
||||
catchToResponseData,
|
||||
),
|
||||
}),
|
||||
),
|
||||
onRight: (response) => ({
|
||||
status: 200,
|
||||
response: pipe(
|
||||
response,
|
||||
Client.encodeBody(api.response),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
onRight: (body) => pipe(
|
||||
body,
|
||||
encodeBody(api.response, 200),
|
||||
catchToResponseData,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const { body, headers } = yield* response;
|
||||
return new Response(body, { status, headers });
|
||||
return response;
|
||||
});
|
||||
|
||||
return Effect.catchAll(effect, Effect.succeed);
|
||||
};
|
||||
};
|
||||
|
||||
const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 });
|
||||
const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 });
|
||||
const RESPONSE_DATA_API_NOT_FOUND = emptyResponse(404);
|
||||
const RESPONSE_DATA_API_NOT_IMPLEMENTED = emptyResponse(501);
|
||||
const RESPONSE_DATA_UNRECOVERABLE_ERROR = emptyResponse(500);
|
||||
|
||||
export const decodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
||||
const decoder = Schema.decodeUnknown(schema);
|
||||
return (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
|
||||
body,
|
||||
Body.bytes,
|
||||
Effect.flatMap((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()),
|
||||
Effect.flatMap(Cbor.decode),
|
||||
Effect.catchTag("NoSuchElementException", () => Effect.succeed(undefined)),
|
||||
Effect.flatMap(decoder),
|
||||
Effect.tapErrorTag("ParseError", (error) => pipe(
|
||||
error,
|
||||
ParseResult.TreeFormatter.formatError,
|
||||
Effect.flatMap(Console.error),
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
export const encodeBody = <A, Status extends ContentfulStatusCode>(schema: Schema.Schema<A, any>, status: Status) => {
|
||||
const encoder = Cbor.encodeSchema(schema);
|
||||
return (a: A) => pipe(
|
||||
a,
|
||||
Option.fromNullable,
|
||||
Effect.transposeMapOption(encoder),
|
||||
Effect.map(Option.match({
|
||||
onNone: () => emptyResponse(status),
|
||||
onSome: bodyResponse(status),
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,17 +33,16 @@ const app = new Hono()
|
||||
// Callback URL with query response type
|
||||
if (code !== undefined || state !== undefined) {
|
||||
yield* Authentication.getAndProcessIdToken(code ?? null, state ?? null);
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
return ctx.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}
|
||||
|
||||
// Initial login request; redirect to identity provider
|
||||
const res = yield* pipe(
|
||||
const url = yield* pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
||||
Effect.map((url) => Response.redirect(url)),
|
||||
);
|
||||
|
||||
return res;
|
||||
return ctx.redirect(url);
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
@@ -58,7 +57,7 @@ const app = new Hono()
|
||||
|
||||
yield* Authentication.getAndProcessIdToken(code, state);
|
||||
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
return ctx.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
@@ -87,17 +86,22 @@ const app = new Hono()
|
||||
const authenticationLayer = Authentication.Live(ctx);
|
||||
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||
|
||||
const response = await pipe(
|
||||
const { body, status } = await pipe(
|
||||
handle(req),
|
||||
Effect.provide(layers),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
for (const [name, value] of CORS_HEADERS) {
|
||||
response.headers.set(name, value);
|
||||
ctx.header(name, value);
|
||||
}
|
||||
|
||||
return response;
|
||||
if (body !== null) {
|
||||
ctx.header("Content-Type", "application/cbor");
|
||||
return ctx.body(body, status);
|
||||
} else {
|
||||
return ctx.body(body, status);
|
||||
}
|
||||
})
|
||||
.use("/assets/*", serveStatic({ root: FRONTEND_ROOT }))
|
||||
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));
|
||||
|
||||
Reference in New Issue
Block a user