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",
|
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
"js/ts.preferences.autoImportFileExcludePatterns": [
|
||||||
"@radix-ui",
|
"@radix-ui",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
import { registerEncoder } from "cbor2/encoder";
|
import { registerEncoder } from "cbor2/encoder";
|
||||||
import * as Api from "common/Api";
|
import * as Api from "common/Api";
|
||||||
|
import * as Body from "common/Body";
|
||||||
import * as Cbor from "common/Cbor";
|
import * as Cbor from "common/Cbor";
|
||||||
import * as Client from "common/Client";
|
import { Cause, Console, Effect, Either, HashMap, Inspectable, Option, ParseResult, pipe, Schema } from "effect";
|
||||||
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
|
|
||||||
import type { HonoRequest } from "hono";
|
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)]);
|
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
|
/* 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
|
* it the TypeScript parser in VS Code sort of blows up and the syntax colors
|
||||||
* turn wrong.
|
* 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,
|
error,
|
||||||
Inspectable.toJSON,
|
Inspectable.toJSON,
|
||||||
Cbor.encode,
|
Cbor.encode,
|
||||||
Effect.map(Client.cborBody),
|
|
||||||
Effect.matchEffect({
|
Effect.matchEffect({
|
||||||
onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })),
|
onSuccess: (body) => Effect.fail<ResponseData.Any>(bodyResponse(500)(body)),
|
||||||
onFailure: () => Effect.fail(new Response(null, { status: 500 })),
|
onFailure: () => Effect.fail<ResponseData.Any>(RESPONSE_DATA_UNRECOVERABLE_ERROR),
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -32,26 +61,26 @@ export const implement = <
|
|||||||
impl: Impl,
|
impl: Impl,
|
||||||
): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
|
): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
|
||||||
return (honoRequest) => {
|
return (honoRequest) => {
|
||||||
/* Force both return types to be `Response`. We can use the error route
|
/* Force both return types to be `ResponseData`. We can use the error
|
||||||
* for it's short-circuit capabilities.
|
* 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 key = honoRequest.param("key");
|
||||||
const maybeApi = HashMap.get(bundle.map, key);
|
const maybeApi = HashMap.get(bundle.map, key);
|
||||||
if (Option.isNone(maybeApi)) {
|
if (Option.isNone(maybeApi)) {
|
||||||
return RESPONSE_API_NOT_FOUND;
|
return RESPONSE_DATA_API_NOT_FOUND;
|
||||||
}
|
}
|
||||||
const { value: api } = maybeApi;
|
const { value: api } = maybeApi;
|
||||||
|
|
||||||
const fn = impl[key];
|
const fn = impl[key];
|
||||||
if (fn === undefined) {
|
if (fn === undefined) {
|
||||||
return RESPONSE_API_NOT_IMPLEMENTED;
|
return RESPONSE_DATA_API_NOT_IMPLEMENTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = yield* pipe(
|
const request = yield* pipe(
|
||||||
honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> },
|
honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> },
|
||||||
Client.decodeBody(api.request),
|
decodeBody(api.request),
|
||||||
catchToResponse,
|
catchToResponseData,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = yield* pipe(
|
const result = yield* pipe(
|
||||||
@@ -61,51 +90,72 @@ export const implement = <
|
|||||||
Effect.either,
|
Effect.either,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { status, response } = pipe(
|
const response = yield* pipe(
|
||||||
result,
|
result,
|
||||||
Either.match({
|
Either.match({
|
||||||
onLeft: (cause) => pipe(
|
onLeft: (cause) => pipe(
|
||||||
cause,
|
cause,
|
||||||
Cause.failureOrCause,
|
Cause.failureOrCause,
|
||||||
Either.match({
|
Either.match({
|
||||||
onLeft: (error) => ({
|
onLeft: (body) => pipe(
|
||||||
status: 400,
|
body,
|
||||||
response: pipe(
|
encodeBody(api.error, 400),
|
||||||
error,
|
catchToResponseData,
|
||||||
Client.encodeBody(api.error),
|
),
|
||||||
catchToResponse,
|
onRight: (die) => pipe(
|
||||||
),
|
die,
|
||||||
}),
|
Inspectable.toJSON,
|
||||||
onRight: (die) => ({
|
Cbor.encode,
|
||||||
status: 500,
|
Effect.map(bodyResponse(500)),
|
||||||
response: pipe(
|
catchToResponseData,
|
||||||
die,
|
),
|
||||||
Inspectable.toJSON,
|
|
||||||
Cbor.encode,
|
|
||||||
Effect.map(Client.cborBody),
|
|
||||||
catchToResponse,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onRight: (response) => ({
|
onRight: (body) => pipe(
|
||||||
status: 200,
|
body,
|
||||||
response: pipe(
|
encodeBody(api.response, 200),
|
||||||
response,
|
catchToResponseData,
|
||||||
Client.encodeBody(api.response),
|
),
|
||||||
catchToResponse,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { body, headers } = yield* response;
|
return response;
|
||||||
return new Response(body, { status, headers });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Effect.catchAll(effect, Effect.succeed);
|
return Effect.catchAll(effect, Effect.succeed);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 });
|
const RESPONSE_DATA_API_NOT_FOUND = emptyResponse(404);
|
||||||
const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 });
|
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
|
// Callback URL with query response type
|
||||||
if (code !== undefined || state !== undefined) {
|
if (code !== undefined || state !== undefined) {
|
||||||
yield* Authentication.getAndProcessIdToken(code ?? null, state ?? null);
|
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
|
// Initial login request; redirect to identity provider
|
||||||
const res = yield* pipe(
|
const url = yield* pipe(
|
||||||
Authentication.Authentication,
|
Authentication.Authentication,
|
||||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
||||||
Effect.map((url) => Response.redirect(url)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return res;
|
return ctx.redirect(url);
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
@@ -58,7 +57,7 @@ const app = new Hono()
|
|||||||
|
|
||||||
yield* Authentication.getAndProcessIdToken(code, state);
|
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(
|
}).pipe(
|
||||||
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
@@ -87,17 +86,22 @@ const app = new Hono()
|
|||||||
const authenticationLayer = Authentication.Live(ctx);
|
const authenticationLayer = Authentication.Live(ctx);
|
||||||
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||||
|
|
||||||
const response = await pipe(
|
const { body, status } = await pipe(
|
||||||
handle(req),
|
handle(req),
|
||||||
Effect.provide(layers),
|
Effect.provide(layers),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [name, value] of CORS_HEADERS) {
|
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("/assets/*", serveStatic({ root: FRONTEND_ROOT }))
|
||||||
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));
|
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));
|
||||||
|
|||||||
Reference in New Issue
Block a user