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
5 changes: 5 additions & 0 deletions .changeset/ff26db6b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@croco/transports-http": patch
---

- fix: cover DI request scope in HTTP requests
257 changes: 255 additions & 2 deletions packages/transports-http/src/tests/FrameworkIntegration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import "reflect-metadata";
import { Context as FrameworkContext, Container } from "@croco/framework-context";
import {
Component,
Context as FrameworkContext,
Container,
type DependencyGraphProvider,
} from "@croco/framework-context";
import { Logger } from "@croco/framework-logger";
import {
Body,
type CallHandler,
type Constructor,
Controller,
type ExecutionContext,
Get,
Expand Down Expand Up @@ -69,6 +75,93 @@ type LifecycleResponse = {
};
};

type DiLifecycleGraphProvider = {
token: string;
scope: DependencyGraphProvider["scope"] | null;
dependencies: readonly string[];
};

type DiLifecycleResponse = {
id: string;
contextActive: boolean;
requestId: string | null;
singletonId: string;
injectedRequestScopedId: string;
directRequestScopedId: string;
repeatedRequestScopedId: string;
graph: {
status: "ready" | "failed";
roots: readonly string[];
providers: readonly DiLifecycleGraphProvider[];
diagnostics: readonly {
code: string;
token: string;
status: string;
path: readonly string[];
}[];
};
};

let lifecycleInstanceSequence = 0;

class HttpSingletonLifecycleProvider {
readonly id = `singleton-${++lifecycleInstanceSequence}`;
}

class HttpRequestScopedLifecycleProvider {
readonly id = `request-${++lifecycleInstanceSequence}`;
}

@Controller("/framework/integration/di")
class DiLifecycleController {
constructor(
private readonly singletonProvider: HttpSingletonLifecycleProvider,
private readonly requestScopedProvider: HttpRequestScopedLifecycleProvider,
) {}

@Get("/lifecycle/:id")
lifecycle(@Param("id") id: string): DiLifecycleResponse {
const directRequestScoped = Container.get(HttpRequestScopedLifecycleProvider);
const repeatedRequestScoped = Container.get(HttpRequestScopedLifecycleProvider);
const manifest = Container.createDependencyGraphManifest({ roots: [DiLifecycleController] });
const context = FrameworkContext.get();

return {
id,
contextActive: FrameworkContext.isActive(),
requestId: context?.requestId ?? null,
singletonId: this.singletonProvider.id,
injectedRequestScopedId: this.requestScopedProvider.id,
directRequestScopedId: directRequestScoped.id,
repeatedRequestScopedId: repeatedRequestScoped.id,
graph: {
status: manifest.status,
roots: manifest.roots,
providers: manifest.providers.map((provider) => ({
token: provider.token,
scope: provider.scope ?? null,
dependencies: provider.dependencies,
})),
diagnostics: manifest.diagnostics.map((diagnostic) => ({
code: diagnostic.code,
token: diagnostic.token,
status: diagnostic.status,
path: diagnostic.path,
})),
},
};
}
}

Reflect.defineMetadata("design:paramtypes", [], HttpSingletonLifecycleProvider);
Reflect.defineMetadata("design:paramtypes", [], HttpRequestScopedLifecycleProvider);
Reflect.defineMetadata(
"design:paramtypes",
[HttpSingletonLifecycleProvider, HttpRequestScopedLifecycleProvider],
DiLifecycleController,
);
const DiLifecycleControllerToken = DiLifecycleController as unknown as Constructor;

class ResponseEnvelopeInterceptor implements Interceptor<ExecutionContext> {
async intercept(context: ExecutionContext, next: CallHandler): Promise<unknown> {
const result = await next.handle();
Expand Down Expand Up @@ -182,6 +275,31 @@ describe("Framework integration", () => {
};
}

function createGetLambdaEvent(path: string, requestId: string): LambdaEvent {
return createLambdaEvent({
rawPath: path,
rawQueryString: "",
requestContext: {
accountId: "123456789012",
apiId: "api-123",
domainName: "example.execute-api.ap-northeast-2.amazonaws.com",
domainPrefix: "example",
http: {
method: "GET",
path,
protocol: "HTTP/1.1",
sourceIp: "127.0.0.1",
userAgent: "vitest",
},
requestId,
routeKey: "$default",
stage: "$default",
time: "17/Mar/2026:12:00:00 +0000",
timeEpoch: 1710676800000,
},
});
}

async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
Expand All @@ -205,6 +323,7 @@ describe("Framework integration", () => {

beforeEach(() => {
Container.reset();
lifecycleInstanceSequence = 0;
const logger = {
info: () => {},
warn: () => {},
Expand All @@ -214,9 +333,12 @@ describe("Framework integration", () => {
Container.set(Logger, logger);
Container.set(ErrorHandler, new ErrorHandler(logger));
Container.set(HealthCheckRegistry, new HealthCheckRegistry());
Component({ scope: "singleton" })(HttpSingletonLifecycleProvider);
Component({ scope: "request" })(HttpRequestScopedLifecycleProvider);
Component({ scope: "request" })(DiLifecycleController);

app = createApp({
controllers: [FrameworkIntegrationController],
controllers: [FrameworkIntegrationController, DiLifecycleControllerToken],
securityValidation: "off",
});
});
Expand Down Expand Up @@ -244,6 +366,61 @@ describe("Framework integration", () => {
expect(body.requestId).toEqual(expect.any(String));
});

it("reuses request-scoped providers inside one Node fetch request and isolates separate requests", async () => {
const firstResponse = await app.fetch(
new Request("http://localhost/framework/integration/di/lifecycle/fetch-1"),
);
const secondResponse = await app.fetch(
new Request("http://localhost/framework/integration/di/lifecycle/fetch-2"),
);

expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);

const first = await readJson<DiLifecycleResponse>(firstResponse);
const second = await readJson<DiLifecycleResponse>(secondResponse);

expect(first).toMatchObject({
id: "fetch-1",
contextActive: true,
graph: {
status: "ready",
roots: ["DiLifecycleController"],
diagnostics: [],
},
});
expect(first.requestId).toEqual(expect.any(String));
expect(first.injectedRequestScopedId).toBe(first.directRequestScopedId);
expect(first.directRequestScopedId).toBe(first.repeatedRequestScopedId);
expect(first.graph.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
token: "DiLifecycleController",
scope: "request",
dependencies: expect.arrayContaining([
"HttpSingletonLifecycleProvider",
"HttpRequestScopedLifecycleProvider",
]),
}),
expect.objectContaining({
token: "HttpSingletonLifecycleProvider",
scope: "singleton",
}),
expect.objectContaining({
token: "HttpRequestScopedLifecycleProvider",
scope: "request",
}),
]),
);

expect(second.id).toBe("fetch-2");
expect(second.singletonId).toBe(first.singletonId);
expect(second.injectedRequestScopedId).toBe(second.directRequestScopedId);
expect(second.directRequestScopedId).toBe(second.repeatedRequestScopedId);
expect(second.directRequestScopedId).not.toBe(first.directRequestScopedId);
expect(second.requestId).not.toBe(first.requestId);
});

it("serializes zod body validation failures as Problem Details", async () => {
const response = await app.fetch(
new Request("http://localhost/framework/integration/widgets", {
Expand Down Expand Up @@ -422,4 +599,80 @@ describe("Framework integration", () => {
});
expect(body.requestId).toEqual(expect.any(String));
});

it("reuses request-scoped providers inside one Lambda request and isolates separate invocations", async () => {
const handler = app.lambdaHandler();
const firstResponse = await handler(
createGetLambdaEvent("/framework/integration/di/lifecycle/lambda-1", "gateway-lambda-1"),
createLambdaContext(),
);
const secondResponse = await handler(
createGetLambdaEvent("/framework/integration/di/lifecycle/lambda-2", "gateway-lambda-2"),
createLambdaContext(),
);

expect(firstResponse.statusCode).toBe(200);
expect(secondResponse.statusCode).toBe(200);

const first = JSON.parse(firstResponse.body ?? "{}") as DiLifecycleResponse;
const second = JSON.parse(secondResponse.body ?? "{}") as DiLifecycleResponse;

expect(first).toMatchObject({
id: "lambda-1",
contextActive: true,
requestId: "gateway-lambda-1",
graph: {
status: "ready",
roots: ["DiLifecycleController"],
diagnostics: [],
},
});
expect(first.injectedRequestScopedId).toBe(first.directRequestScopedId);
expect(first.directRequestScopedId).toBe(first.repeatedRequestScopedId);

expect(second).toMatchObject({
id: "lambda-2",
contextActive: true,
requestId: "gateway-lambda-2",
});
expect(second.singletonId).toBe(first.singletonId);
expect(second.injectedRequestScopedId).toBe(second.directRequestScopedId);
expect(second.directRequestScopedId).toBe(second.repeatedRequestScopedId);
expect(second.directRequestScopedId).not.toBe(first.directRequestScopedId);
});

it("reports singleton to request-scope mismatches in DI graph diagnostics", () => {
Container.reset();

class InvalidRequestScopedProvider {}

class InvalidSingletonProvider {
constructor(readonly requestScopedProvider: InvalidRequestScopedProvider) {}
}

Reflect.defineMetadata("design:paramtypes", [], InvalidRequestScopedProvider);
Reflect.defineMetadata(
"design:paramtypes",
[InvalidRequestScopedProvider],
InvalidSingletonProvider,
);
Component({ scope: "request" })(InvalidRequestScopedProvider);
Component({ scope: "singleton" })(InvalidSingletonProvider);

const manifest = Container.createDependencyGraphManifest({
roots: [InvalidSingletonProvider],
});

expect(manifest.status).toBe("failed");
expect(manifest.diagnostics).toContainEqual(
expect.objectContaining({
code: "CROCO_DI_003",
legacyCode: "framework-context/di-scope-mismatch",
severity: "error",
token: "InvalidRequestScopedProvider",
status: "scope-mismatch",
path: ["InvalidSingletonProvider", "InvalidRequestScopedProvider"],
}),
);
});
});
Loading