Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/api/src/execution-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Context from "effect/Context";

export namespace ExecutionContext {
export interface Interface {
readonly waitUntil: (promise: Promise<unknown>) => void;
}

export class Service extends Context.Service<Service, Interface>()(
"@leuchtturm/api/ExecutionContext",
) {}

export function make(executionContext: Interface) {
return Context.make(
Service,
Service.of({
waitUntil: executionContext.waitUntil.bind(executionContext),
}),
);
}
}
38 changes: 6 additions & 32 deletions apps/api/src/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import {
FeatureFlagError,
FeatureFlagEvaluationError,
FeatureFlagProviderRequestError,
} from "@leuchtturm/api/feature-flags/errors";
import { FeatureFlagError } from "@leuchtturm/api/feature-flags/errors";
import { Posthog } from "@leuchtturm/api/posthog";

export namespace FeatureFlags {
Expand All @@ -23,35 +18,14 @@ export namespace FeatureFlags {
) {}

export const layer = Layer.effect(Service)(
Effect.sync(() => {
const client = Posthog.create();
Effect.gen(function* () {
const posthog = yield* Posthog.Service;

return Service.of({
isEnabled: (key, userId) =>
Effect.tryPromise({
try: () =>
client.getFeatureFlagResult(key, userId, {
sendFeatureFlagEvents: false,
}),
catch: (cause) => cause,
}).pipe(
Effect.tapCause((cause) =>
Effect.annotateCurrentSpan({
"error.original_cause": Cause.pretty(cause),
}),
),
Effect.mapError(
() =>
new FeatureFlagProviderRequestError({
operation: `Evaluate feature flag ${key} for user ${userId}`,
}),
),
Effect.filterOrFail(
(result) => result !== undefined,
() => new FeatureFlagEvaluationError({ key, userId }),
),
Effect.map((result) => result.enabled),
),
posthog
.getFeatureFlagResult(key, userId, { sendFeatureFlagEvents: false })
.pipe(Effect.map((result) => result.enabled)),
});
}),
);
Expand Down
161 changes: 88 additions & 73 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -9,6 +10,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { Resource, wrapCloudflareHandler } from "sst/resource/cloudflare";

import { Contract } from "@leuchtturm/api/contract";
import { ExecutionContext } from "@leuchtturm/api/execution-context";
import { AuthHandler } from "@leuchtturm/api/handlers/auth/index";
import { BillingHandler } from "@leuchtturm/api/handlers/billing/index";
import { DocsHandler } from "@leuchtturm/api/handlers/docs/index";
Expand All @@ -17,93 +19,106 @@ import { ZeroHandler } from "@leuchtturm/api/handlers/zero/index";
import { AuthMiddleware } from "@leuchtturm/api/middleware/auth-middleware";
import { RequestContext } from "@leuchtturm/api/middleware/request-context";
import { Observability } from "@leuchtturm/api/observability";
import { Posthog } from "@leuchtturm/api/posthog";
import { Auth } from "@leuchtturm/core/auth";
import { Database } from "@leuchtturm/core/database";
import { InternalServerError } from "@leuchtturm/core/errors";
import { ZeroDatabase } from "@leuchtturm/zero/zero-database";

const apiRoutes = Layer.mergeAll(
HttpApiBuilder.layer(Contract.Api, {
openapiPath: "/open-api",
}).pipe(
Layer.provide(
Layer.mergeAll(
HealthHandler.layer(Contract.Api),
BillingHandler.layer(Contract.Api),
ZeroHandler.layer(Contract.Api),
AuthHandler.layer(Contract.Api),
namespace Api {
const routes = Layer.mergeAll(
HttpApiBuilder.layer(Contract.Api, {
openapiPath: "/open-api",
}).pipe(
Layer.provide(
Layer.mergeAll(
HealthHandler.layer(Contract.Api),
BillingHandler.layer(Contract.Api),
ZeroHandler.layer(Contract.Api),
AuthHandler.layer(Contract.Api),
),
),
Layer.provide(AuthMiddleware.layer),
),
Layer.provide(AuthMiddleware.layer),
),
DocsHandler.layer,
);
DocsHandler.layer,
);

const { handler } = HttpEffect.toWebHandlerLayer(
HttpRouter.toHttpEffect(apiRoutes).pipe(Effect.flatten),
HttpServer.layerServices,
{
middleware: (app) =>
HttpMiddleware.cors({
allowedOrigins: (origin) => {
if (!origin) return false;
if (Resource.App.stage !== "prod") return true;
const { handler } = HttpEffect.toWebHandlerLayer(
HttpRouter.toHttpEffect(routes).pipe(Effect.flatten),
HttpServer.layerServices,
{
middleware: (app) =>
app.pipe(
HttpMiddleware.logger,
HttpMiddleware.make((app) =>
Effect.gen(function* () {
const observability = yield* Observability.Service;
const requestContext = yield* RequestContext.Service;

return origin === `https://${Resource.Dns.AppDomain}`;
},
credentials: true,
})(RequestContext.middleware(Observability.middleware(HttpMiddleware.logger(app)))),
},
);
return yield* app.pipe(
Effect.tapCause((cause) =>
observability.captureUnexpectedCause(cause, requestContext),
),
);
}),
),
RequestContext.middleware,
HttpMiddleware.cors({
allowedOrigins: (origin) => {
if (!origin) return false;
if (Resource.App.stage !== "prod") return true;

const handleRequest = Effect.fn("handleRequest")(function* (
request: Request,
executionContext: Pick<ExecutionContext, "waitUntil">,
) {
const baseContext = RequestContext.make(request, executionContext);
return origin === `https://${Resource.Dns.AppDomain}`;
},
credentials: true,
}),
Observability.middleware,
),
},
);

return yield* Observability.withRequestContext(baseContext)(
Effect.scoped(
Effect.gen(function* () {
const observabilityContext = yield* Effect.context<never>();
const servicesContext = yield* Layer.build(
Layer.mergeAll(Auth.defaultLayer, ZeroDatabase.layer).pipe(
Layer.provideMerge(Database.layer(Resource.Database.connectionString)),
),
);
const requestContext = Context.merge(
observabilityContext,
Context.merge(baseContext, servicesContext),
);
export const handleRequest = Effect.fn("handleRequest")(function* (
request: Request,
executionContext: ExecutionContext.Interface,
) {
const context = Context.merge(
RequestContext.make(request),
ExecutionContext.make(executionContext),
);

return yield* Effect.provideContext(
Effect.tryPromise({
try: () => handler(request, requestContext),
catch: (cause) => cause,
}).pipe(
Effect.tap((response) =>
Effect.annotateCurrentSpan({ "http.response.status_code": response.status }),
),
Effect.mapError(() => new InternalServerError()),
return yield* Effect.gen(function* () {
const services = yield* Layer.mergeAll(
Observability.layer.pipe(Layer.provide(Posthog.layer)),
Auth.defaultLayer,
ZeroDatabase.layer,
).pipe(Layer.provideMerge(Database.layer(Resource.Database.connectionString)), Layer.build);
const observability = Context.get(services, Observability.Service);
const handlerContext = Context.merge(context, services);

return yield* Effect.tryPromise({
try: () => handler(request, handlerContext),
catch: (cause) => cause,
}).pipe(
Effect.tapError((error) =>
observability.captureUnexpectedCause(
Cause.die(error),
Context.get(context, RequestContext.Service),
),
requestContext,
);
}),
).pipe(
Effect.withSpan(`${request.method} ${request.url}`, {
attributes: {
"http.request.method": request.method,
"url.full": request.url,
},
kind: "server",
root: true,
}),
),
);
});
),
Effect.mapError(() => new InternalServerError()),
Effect.ensuring(
Effect.sync(() => {
executionContext.waitUntil(Effect.runPromise(observability.flush));
}),
),
Effect.provideContext(handlerContext),
);
}).pipe(Effect.scoped, Effect.provideContext(context));
});
}

export default wrapCloudflareHandler({
fetch(request: Request, _env: unknown, ctx: ExecutionContext) {
return Effect.runPromise(handleRequest(request, ctx));
fetch(request: Request, _env: unknown, ctx: globalThis.ExecutionContext) {
return Effect.runPromise(Api.handleRequest(request, ctx));
},
});
4 changes: 1 addition & 3 deletions apps/api/src/middleware/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ export namespace RequestContext {
readonly method: string;
readonly path: string;
readonly requestId: string;
readonly waitUntil: ExecutionContext["waitUntil"];
}

export class Service extends Context.Service<Service, Interface>()(
"@leuchtturm/api/RequestContext",
) {}

export function make(request: Request, executionContext: Pick<ExecutionContext, "waitUntil">) {
export function make(request: Request) {
const requestId = Option.fromNullishOr(request.headers.get("x-request-id")).pipe(
Option.map((value) => value.trim()),
Option.flatMap(Schema.decodeUnknownOption(Schema.String.check(Schema.isUUID(4)))),
Expand All @@ -31,7 +30,6 @@ export namespace RequestContext {
method: request.method,
path: request.url,
requestId,
waitUntil: executionContext.waitUntil.bind(executionContext),
}),
);
}
Expand Down
Loading
Loading