diff options
Diffstat (limited to 'u/server')
-rw-r--r-- | u/server/activity/fourohfour.ts | 36 | ||||
-rw-r--r-- | u/server/activity/health.ts | 67 | ||||
-rw-r--r-- | u/server/activity/mod.ts | 13 | ||||
-rw-r--r-- | u/server/filter/json.ts | 51 | ||||
-rw-r--r-- | u/server/filter/method.ts | 41 | ||||
-rw-r--r-- | u/server/filter/mod.ts | 33 | ||||
-rw-r--r-- | u/server/mod.ts | 7 | ||||
-rw-r--r-- | u/server/request.ts | 57 | ||||
-rw-r--r-- | u/server/response.ts | 84 |
9 files changed, 389 insertions, 0 deletions
diff --git a/u/server/activity/fourohfour.ts b/u/server/activity/fourohfour.ts new file mode 100644 index 0000000..6449abd --- /dev/null +++ b/u/server/activity/fourohfour.ts @@ -0,0 +1,36 @@ +import { + type IActivity, + type ITraceable, + JsonResponse, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +const messages = [ + "(≧ω≦)ゞ Oopsie! This endpoint has gone a-404-dable!", + "。゚(。ノωヽ。)゚。 Meow-t found! Your API call ran away!", + "404-bidden! But like...in a cute way (・`ω´・) !", + "(=①ω①=) This endpoint is hiss-terically missing!", + "┐(´∀`)┌ Whoopsie fluff! No API here!", + "(つ≧▽≦)つ Your data went on a paw-sible vacation!", + "(ꈍᴗꈍ) Uwu~ not found, but found our hearts instead!", + "ヽ(;▽;)ノ Eep! This route has ghosted you~", +]; +const randomFourOhFour = () => messages[Math.random() * messages.length]; + +export interface IFourOhFourActivity { + fourOhFour: IActivity; +} + +export class FourOhFourActivityImpl implements IFourOhFourActivity { + public fourOhFour( + req: ITraceable<PenguenoRequest, ServerTrace>, + ) { + return req + .move( + new JsonResponse(req, randomFourOhFour(), { status: 404 }), + ) + .map((resp) => Promise.resolve(resp.get())) + .get(); + } +} diff --git a/u/server/activity/health.ts b/u/server/activity/health.ts new file mode 100644 index 0000000..0f54a99 --- /dev/null +++ b/u/server/activity/health.ts @@ -0,0 +1,67 @@ +import { + type IActivity, + type IEither, + type ITraceable, + JsonResponse, + LogLevel, + type Mapper, + Metric, + type PenguenoRequest, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +export enum HealthCheckInput { + CHECK, +} +export enum HealthCheckOutput { + YAASSSLAYQUEEN, +} + +export interface IHealthCheckActivity { + checkHealth: IActivity; +} + +const healthCheckMetric = Metric.fromName("Health"); +export interface HealthChecker extends + Mapper< + ITraceable<HealthCheckInput, ServerTrace>, + Promise<IEither<Error, HealthCheckOutput>> + > {} +export class HealthCheckActivityImpl implements IHealthCheckActivity { + constructor( + private readonly check: HealthChecker, + ) {} + + public checkHealth(req: ITraceable<PenguenoRequest, ServerTrace>) { + return req + .bimap(TraceUtil.withFunctionTrace(this.checkHealth)) + .bimap(TraceUtil.withMetricTrace(healthCheckMetric)) + .flatMap((r) => r.move(HealthCheckInput.CHECK).map(this.check)) + .peek(TraceUtil.promiseify((h) => + h.get().fold((err) => { + if (err) { + h.trace.trace(healthCheckMetric.failure); + h.trace.addTrace(LogLevel.ERROR).trace(`${err}`); + return; + } + h.trace.trace(healthCheckMetric.success); + }) + )) + .map(TraceUtil.promiseify((h) => + h.get() + .mapBoth( + () => "oh no, i need to eat more vegetables (。•́︿•̀。)...", + () => "think im healthy!! (✿˘◡˘) ready to do work~", + ) + .fold((errMsg, okMsg) => + new JsonResponse( + req, + errMsg ?? okMsg, + { status: errMsg ? 500 : 200 }, + ) + ) + )) + .get(); + } +} diff --git a/u/server/activity/mod.ts b/u/server/activity/mod.ts new file mode 100644 index 0000000..82d8ec4 --- /dev/null +++ b/u/server/activity/mod.ts @@ -0,0 +1,13 @@ +import type { + ITraceable, + PenguenoRequest, + PenguenoResponse, + ServerTrace, +} from "@emprespresso/pengueno"; + +export interface IActivity { + (req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse>; +} + +export * from "./health.ts"; +export * from "./fourohfour.ts"; diff --git a/u/server/filter/json.ts b/u/server/filter/json.ts new file mode 100644 index 0000000..4a2961e --- /dev/null +++ b/u/server/filter/json.ts @@ -0,0 +1,51 @@ +import { + Either, + type IEither, + type ITraceable, + LogLevel, + Metric, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +export interface JsonTransformer<R, ParsedJson = unknown> { + (json: ITraceable<ParsedJson, ServerTrace>): IEither<PenguenoError, R>; +} + +const ParseJsonMetric = Metric.fromName("JsonParse"); +export const jsonModel = <MessageT>( + jsonTransformer: JsonTransformer<MessageT>, +): RequestFilter<MessageT> => +(r: ITraceable<PenguenoRequest, ServerTrace>) => + r.bimap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .map((j) => + Either.fromFailableAsync<Error, MessageT>(j.get().json()) + .then((either) => + either.mapLeft((errReason) => { + j.trace.addTrace(LogLevel.WARN).trace(`${errReason}`); + return new PenguenoError( + "seems to be invalid JSON (>//<) can you fix?", + 400, + ); + }) + ) + ) + .peek( + TraceUtil.promiseify((traceableEither) => + traceableEither.get().mapBoth( + () => traceableEither.trace.trace(ParseJsonMetric.failure), + () => traceableEither.trace.trace(ParseJsonMetric.success), + ) + ), + ) + .map( + TraceUtil.promiseify((traceableEitherJson) => + traceableEitherJson.get() + .mapRight(traceableEitherJson.move) + .flatMap(jsonTransformer) + ), + ) + .get(); diff --git a/u/server/filter/method.ts b/u/server/filter/method.ts new file mode 100644 index 0000000..6b0419d --- /dev/null +++ b/u/server/filter/method.ts @@ -0,0 +1,41 @@ +import { + Either, + type ITraceable, + LogLevel, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +type HttpMethod = + | "POST" + | "GET" + | "HEAD" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE" + | "PATCH"; + +export const requireMethod = ( + methods: Array<HttpMethod>, +): RequestFilter<HttpMethod> => +(req: ITraceable<PenguenoRequest, ServerTrace>) => + req.bimap(TraceUtil.withFunctionTrace(requireMethod)) + .move(Promise.resolve(req.get())) + .map(TraceUtil.promiseify((t) => { + const { method: _method } = t.get(); + const method = <HttpMethod> _method; + if (!methods.includes(method)) { + const msg = "that's not how you pet me (⋟﹏⋞)~"; + t.trace.addTrace(LogLevel.WARN).trace(msg); + return Either.left<PenguenoError, HttpMethod>( + new PenguenoError(msg, 405), + ); + } + return Either.right<PenguenoError, HttpMethod>(method); + })) + .get(); diff --git a/u/server/filter/mod.ts b/u/server/filter/mod.ts new file mode 100644 index 0000000..bbf37df --- /dev/null +++ b/u/server/filter/mod.ts @@ -0,0 +1,33 @@ +import { + type IEither, + type ITraceable, + LogLevel, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +export enum ErrorSource { + USER = LogLevel.WARN, + SYSTEM = LogLevel.ERROR, +} + +export class PenguenoError extends Error { + public readonly source: ErrorSource; + constructor(message: string, public readonly status: number) { + super(message); + this.source = Math.floor(status / 100) === 4 + ? ErrorSource.USER + : ErrorSource.SYSTEM; + } +} + +export interface RequestFilter< + T, + Err extends PenguenoError = PenguenoError, + RIn = ITraceable<PenguenoRequest, ServerTrace>, +> { + (req: RIn): Promise<IEither<Err, T>>; +} + +export * from "./method.ts"; +export * from "./json.ts"; diff --git a/u/server/mod.ts b/u/server/mod.ts new file mode 100644 index 0000000..866b5f9 --- /dev/null +++ b/u/server/mod.ts @@ -0,0 +1,7 @@ +import type { LogMetricTraceSupplier } from "@emprespresso/pengueno"; +export type ServerTrace = LogMetricTraceSupplier; + +export * from "./activity/mod.ts"; +export * from "./filter/mod.ts"; +export * from "./response.ts"; +export * from "./request.ts"; diff --git a/u/server/request.ts b/u/server/request.ts new file mode 100644 index 0000000..7aa9917 --- /dev/null +++ b/u/server/request.ts @@ -0,0 +1,57 @@ +import { LogMetricTraceable } from "@emprespresso/pengueno"; + +const greetings = [ + "hewwo :D", + "hiya cutie (✿◠‿◠)", + "boop! ૮・ᴥ・ა", + "sending virtual hugs! (づ。◕‿‿◕。)づ", + "stay pawsitive ₍^..^₎⟆", + "⋆。‧˚❆🐧❆˚‧。⋆", +]; +const penguenoGreeting = () => + greetings[Math.floor(Math.random() * greetings.length)]; + +export class PenguenoRequest extends Request { + private constructor( + _input: URL, + _requestInit: RequestInit, + public readonly id: string, + public readonly at: Date, + ) { + super(_input, _requestInit); + } + + public baseResponseHeaders(): Record<string, string> { + const ServerRequestTime = this.at.getTime(); + const ServerResponseTime = Date.now(); + const DeltaTime = ServerResponseTime - ServerRequestTime; + const RequestId = this.id; + + return Object.entries({ + RequestId, + ServerRequestTime, + ServerResponseTime, + DeltaTime, + Hai: penguenoGreeting(), + }).reduce((acc, [key, val]) => ({ ...acc, [key]: (val.toString()) }), {}); + } + + public static from( + request: Request, + ): LogMetricTraceable<PenguenoRequest> { + const id = crypto.randomUUID(); + const url = new URL(request.url); + const { pathname } = url; + const traceSupplier = () => `[${id} <- ${request.method}'d @ ${pathname}]`; + return LogMetricTraceable + .from( + new PenguenoRequest( + url, + { ...request }, + id, + new Date(), + ), + ) + .bimap((_request) => [_request.get(), traceSupplier]); + } +} diff --git a/u/server/response.ts b/u/server/response.ts new file mode 100644 index 0000000..c21819a --- /dev/null +++ b/u/server/response.ts @@ -0,0 +1,84 @@ +import { + type IEither, + isEither, + type ITraceable, + Metric, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +export type ResponseBody = object | string; +export type TResponseInit = ResponseInit & { + status: number; + headers?: Record<string, string>; +}; + +const getResponse = ( + req: PenguenoRequest, + opts: TResponseInit, +): TResponseInit => { + return { + ...opts, + headers: { + ...(req.baseResponseHeaders()), + ...(opts?.headers), + "Content-Type": (opts?.headers?.["Content-Type"] ?? "text/plain") + + "; charset=utf-8", + }, + }; +}; + +const ResponseCodeMetrics = [1, 2, 3, 4, 5].map((x) => + Metric.fromName(`response.${x}xx`) +); +export const getResponseMetric = (status: number) => { + const index = (Math.floor(status / 100)) + 1; + return ResponseCodeMetrics[index] ?? ResponseCodeMetrics[5 - 1]; +}; + +export class PenguenoResponse extends Response { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + msg: BodyInit, + opts: TResponseInit, + ) { + const responseOpts = getResponse(req.get(), opts); + const resMetric = getResponseMetric(opts.status); + req.trace.trace(resMetric.count.withValue(1.0)); + responseOpts.headers; + super(msg, responseOpts); + } +} + +export class JsonResponse extends PenguenoResponse { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + e: BodyInit | IEither<ResponseBody, ResponseBody>, + opts: TResponseInit, + ) { + const optsWithJsonContentType = { + ...opts, + headers: { + ...opts?.headers, + "Content-Type": "application/json", + }, + }; + if (isEither<ResponseBody, ResponseBody>(e)) { + super( + req, + JSON.stringify( + e.fold((err, ok) => err ? ({ error: err! }) : ({ ok: ok! })), + ), + optsWithJsonContentType, + ); + return; + } + super( + req, + JSON.stringify( + (Math.floor(opts.status / 100) < 4) ? { ok: e } : { error: e }, + ), + optsWithJsonContentType, + ); + } +} |