mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 15:30:16 +03:00
chore: Новый черновик DRAFT, удалить старые docs-v
This commit is contained in:
20
examples/demo-backend/src/apps/complex/bootstrap.ts
Normal file
20
examples/demo-backend/src/apps/complex/bootstrap.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { OpenAPIObject } from "@nestjs/swagger";
|
||||
import { configureApplication } from "../../common/configure-application";
|
||||
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
|
||||
import { ComplexAppModule } from "./complex.module";
|
||||
|
||||
export async function createComplexApplication(
|
||||
logger: false | undefined = undefined,
|
||||
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
ComplexAppModule,
|
||||
{ logger },
|
||||
);
|
||||
configureApplication(app);
|
||||
const port = Number(process.env.COMPLEX_PORT ?? 3002);
|
||||
const document = createOpenApiDocument(app, { kind: "complex", port });
|
||||
mountOpenApi(app, document, "Demo Complex API");
|
||||
return { app, document };
|
||||
}
|
||||
175
examples/demo-backend/src/apps/complex/chat.gateway.ts
Normal file
175
examples/demo-backend/src/apps/complex/chat.gateway.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { parse as parseCookie } from "cookie";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import type { Server, Socket } from "socket.io";
|
||||
import { ComplexSessionService, SESSION_COOKIE } from "./complex.auth";
|
||||
import { ComplexStore } from "./complex.store";
|
||||
import type { ChatMessageDto, SendMessageDto } from "./dto/chat.dto";
|
||||
|
||||
interface ChatSocketData {
|
||||
userId: string;
|
||||
organizationIds: string[];
|
||||
}
|
||||
|
||||
interface ChatContextPayload {
|
||||
organizationId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
interface SocketMessagePayload extends ChatContextPayload, SendMessageDto {}
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: "/chat",
|
||||
cors: { origin: true, credentials: true },
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
export class ChatGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly sessions: ComplexSessionService,
|
||||
private readonly store: ComplexStore,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
const cookies = parseCookie(client.handshake.headers.cookie ?? "");
|
||||
const session = this.sessions.get(cookies[SESSION_COOKIE]);
|
||||
const user = session ? this.store.findUserById(session.userId) : undefined;
|
||||
if (!session || !user) {
|
||||
client.emit("chat:error", {
|
||||
code: "SESSION_EXPIRED",
|
||||
message: "Valid demo_session cookie is required.",
|
||||
});
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
client.data = {
|
||||
userId: user.id,
|
||||
organizationIds: user.organizationIds,
|
||||
} satisfies ChatSocketData;
|
||||
this.logger.debug(`Socket ${client.id} connected as ${user.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage("chat:join")
|
||||
join(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
) {
|
||||
try {
|
||||
const data = client.data as ChatSocketData;
|
||||
this.assertOrganization(data, payload.organizationId);
|
||||
this.store.getConversation(
|
||||
payload.organizationId,
|
||||
data.userId,
|
||||
payload.conversationId,
|
||||
);
|
||||
void client.join(this.room(payload.conversationId));
|
||||
return {
|
||||
event: "chat:joined",
|
||||
data: { conversationId: payload.conversationId },
|
||||
};
|
||||
} catch (error) {
|
||||
return { event: "chat:error", data: this.socketError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage("chat:leave")
|
||||
leave(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
) {
|
||||
void client.leave(this.room(payload.conversationId));
|
||||
return {
|
||||
event: "chat:left",
|
||||
data: { conversationId: payload.conversationId },
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage("message:send")
|
||||
send(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SocketMessagePayload,
|
||||
) {
|
||||
try {
|
||||
const data = client.data as ChatSocketData;
|
||||
this.assertOrganization(data, payload.organizationId);
|
||||
const message = this.store.sendMessage(
|
||||
payload.organizationId,
|
||||
data.userId,
|
||||
payload.conversationId,
|
||||
payload,
|
||||
);
|
||||
this.broadcastMessage(message);
|
||||
return { event: "message:ack", data: message };
|
||||
} catch (error) {
|
||||
return { event: "chat:error", data: this.socketError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage("typing:start")
|
||||
typingStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
): void {
|
||||
this.broadcastTyping(client, payload, true);
|
||||
}
|
||||
|
||||
@SubscribeMessage("typing:stop")
|
||||
typingStop(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: ChatContextPayload,
|
||||
): void {
|
||||
this.broadcastTyping(client, payload, false);
|
||||
}
|
||||
|
||||
broadcastMessage(message: ChatMessageDto): void {
|
||||
this.server
|
||||
.to(this.room(message.conversationId))
|
||||
.emit("message:created", message);
|
||||
}
|
||||
|
||||
private broadcastTyping(
|
||||
client: Socket,
|
||||
payload: ChatContextPayload,
|
||||
active: boolean,
|
||||
): void {
|
||||
const data = client.data as ChatSocketData;
|
||||
if (!data.organizationIds.includes(payload.organizationId)) return;
|
||||
client
|
||||
.to(this.room(payload.conversationId))
|
||||
.emit(active ? "typing:started" : "typing:stopped", {
|
||||
conversationId: payload.conversationId,
|
||||
userId: data.userId,
|
||||
});
|
||||
}
|
||||
|
||||
private assertOrganization(
|
||||
data: ChatSocketData,
|
||||
organizationId: string,
|
||||
): void {
|
||||
if (!data.organizationIds.includes(organizationId))
|
||||
throw new Error("Organization is unavailable to this user.");
|
||||
}
|
||||
|
||||
private room(conversationId: string): string {
|
||||
return `conversation:${conversationId}`;
|
||||
}
|
||||
|
||||
private socketError(error: unknown): { code: string; message: string } {
|
||||
return {
|
||||
code: "CHAT_OPERATION_FAILED",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Chat operation failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
261
examples/demo-backend/src/apps/complex/complex.auth.ts
Normal file
261
examples/demo-backend/src/apps/complex/complex.auth.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { CookieOptions, Request } from "express";
|
||||
import type { DemoRequest } from "../../common/request.types";
|
||||
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
|
||||
import {
|
||||
ComplexRole,
|
||||
type ComplexLoginDto,
|
||||
type ComplexUserDto,
|
||||
type CookieSessionResponseDto,
|
||||
} from "./dto/identity.dto";
|
||||
import { ComplexStore } from "./complex.store";
|
||||
|
||||
export const SESSION_COOKIE = "demo_session";
|
||||
export const CSRF_COOKIE = "demo_csrf";
|
||||
const ROLES_METADATA = "complex-roles";
|
||||
|
||||
export interface ComplexDemoRequest extends DemoRequest {
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
token: string;
|
||||
userId: string;
|
||||
csrfToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CreatedSession {
|
||||
token: string;
|
||||
csrfToken: string;
|
||||
expiresAt: string;
|
||||
user: ComplexUserDto;
|
||||
}
|
||||
|
||||
export const ComplexRoles = (...roles: ComplexRole[]) =>
|
||||
SetMetadata(ROLES_METADATA, roles);
|
||||
|
||||
export function sessionCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
export function csrfCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplexSessionService {
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
login(dto: ComplexLoginDto): CreatedSession {
|
||||
const user = this.store.findUserByEmail(dto.email);
|
||||
if (!user || user.password !== dto.password) {
|
||||
throw new UnauthorizedException({
|
||||
code: "INVALID_CREDENTIALS",
|
||||
message: "Email or password is incorrect.",
|
||||
});
|
||||
}
|
||||
return this.create(user.id);
|
||||
}
|
||||
|
||||
get(token: string | undefined): SessionRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return undefined;
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
this.sessions.delete(token);
|
||||
return undefined;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
rotate(token: string): CreatedSession {
|
||||
const existing = this.get(token);
|
||||
if (!existing)
|
||||
throw new UnauthorizedException({
|
||||
code: "SESSION_EXPIRED",
|
||||
message: "Cookie session is missing or expired.",
|
||||
});
|
||||
this.sessions.delete(token);
|
||||
return this.create(existing.userId);
|
||||
}
|
||||
|
||||
revoke(token: string | undefined): void {
|
||||
if (token) this.sessions.delete(token);
|
||||
}
|
||||
|
||||
expire(token: string | undefined): void {
|
||||
const session = token ? this.sessions.get(token) : undefined;
|
||||
if (session) session.expiresAt = 0;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
response(session: CreatedSession): CookieSessionResponseDto {
|
||||
return {
|
||||
data: {
|
||||
user: session.user,
|
||||
csrfToken: session.csrfToken,
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private create(userId: string): CreatedSession {
|
||||
const user = this.store.findUserById(userId);
|
||||
if (!user)
|
||||
throw new UnauthorizedException({
|
||||
code: "USER_NOT_FOUND",
|
||||
message: "Session user no longer exists.",
|
||||
});
|
||||
const ttl = Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000);
|
||||
const record: SessionRecord = {
|
||||
token: randomUUID(),
|
||||
userId,
|
||||
csrfToken: randomUUID(),
|
||||
expiresAt: Date.now() + ttl,
|
||||
};
|
||||
this.sessions.set(record.token, record);
|
||||
return {
|
||||
token: record.token,
|
||||
csrfToken: record.csrfToken,
|
||||
expiresAt: new Date(record.expiresAt).toISOString(),
|
||||
user: this.store.publicUser(user),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplexCookieGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly sessions: ComplexSessionService,
|
||||
private readonly store: ComplexStore,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request>() as ComplexDemoRequest;
|
||||
assertNoForcedAuthFailure(request);
|
||||
const cookies = request.cookies as Record<string, string> | undefined;
|
||||
const token = cookies?.[SESSION_COOKIE];
|
||||
const session = this.sessions.get(token);
|
||||
if (!session)
|
||||
throw new UnauthorizedException({
|
||||
code: "SESSION_EXPIRED",
|
||||
message: "Cookie session is missing or expired.",
|
||||
});
|
||||
const user = this.store.findUserById(session.userId);
|
||||
if (!user)
|
||||
throw new UnauthorizedException({
|
||||
code: "USER_NOT_FOUND",
|
||||
message: "Session user no longer exists.",
|
||||
});
|
||||
request.user = this.store.publicUser(user);
|
||||
request.sessionToken = token;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplexCsrfGuard implements CanActivate {
|
||||
constructor(private readonly sessions: ComplexSessionService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request>() as ComplexDemoRequest;
|
||||
const session = this.sessions.get(request.sessionToken);
|
||||
const headerToken = request.headers["x-csrf-token"];
|
||||
const cookieToken = (
|
||||
request.cookies as Record<string, string> | undefined
|
||||
)?.[CSRF_COOKIE];
|
||||
if (
|
||||
!session ||
|
||||
typeof headerToken !== "string" ||
|
||||
headerToken !== session.csrfToken ||
|
||||
cookieToken !== session.csrfToken
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: "CSRF_INVALID",
|
||||
message:
|
||||
"A matching X-CSRF-Token header and demo_csrf cookie are required.",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplexOrganizationGuard implements CanActivate {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
|
||||
const organizationId = request.headers["x-organization-id"];
|
||||
if (typeof organizationId !== "string" || organizationId.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: "ORGANIZATION_REQUIRED",
|
||||
message: "X-Organization-Id header is required.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!request.user ||
|
||||
!this.store.isMember(request.user.id, organizationId)
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: "ORGANIZATION_FORBIDDEN",
|
||||
message: "The user is not an active member of this organization.",
|
||||
});
|
||||
}
|
||||
request.organizationId = organizationId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplexRolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<ComplexRole[]>(
|
||||
ROLES_METADATA,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required?.length) return true;
|
||||
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
|
||||
if (!request.user || !required.includes(request.user.role as ComplexRole)) {
|
||||
throw new ForbiddenException({
|
||||
code: "ROLE_FORBIDDEN",
|
||||
message: `One of these roles is required: ${required.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
71
examples/demo-backend/src/apps/complex/complex.module.ts
Normal file
71
examples/demo-backend/src/apps/complex/complex.module.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { InfrastructureModule } from "../../common/infrastructure.module";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRolesGuard,
|
||||
ComplexSessionService,
|
||||
} from "./complex.auth";
|
||||
import { ComplexStore } from "./complex.store";
|
||||
import { ChatGateway } from "./chat.gateway";
|
||||
import { ChatController } from "./controllers/chat.controller";
|
||||
import {
|
||||
ComplexCatalogController,
|
||||
ComplexInventoryController,
|
||||
ComplexProductsController,
|
||||
} from "./controllers/catalog.controllers";
|
||||
import {
|
||||
ComplexOrdersController,
|
||||
CustomersController,
|
||||
PaymentsController,
|
||||
ReviewsController,
|
||||
} from "./controllers/commerce.controllers";
|
||||
import {
|
||||
ComplexAuthController,
|
||||
ComplexUsersController,
|
||||
OrganizationsController,
|
||||
} from "./controllers/identity.controllers";
|
||||
import {
|
||||
AuditController,
|
||||
FilesController,
|
||||
JobsController,
|
||||
NotificationsController,
|
||||
} from "./controllers/operations.controllers";
|
||||
import {
|
||||
ComplexHealthController,
|
||||
ComplexTestingController,
|
||||
} from "./controllers/system.controllers";
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
controllers: [
|
||||
ComplexHealthController,
|
||||
ComplexAuthController,
|
||||
ComplexUsersController,
|
||||
OrganizationsController,
|
||||
ComplexProductsController,
|
||||
ComplexCatalogController,
|
||||
ComplexInventoryController,
|
||||
CustomersController,
|
||||
ComplexOrdersController,
|
||||
PaymentsController,
|
||||
ReviewsController,
|
||||
NotificationsController,
|
||||
FilesController,
|
||||
AuditController,
|
||||
JobsController,
|
||||
ChatController,
|
||||
ComplexTestingController,
|
||||
],
|
||||
providers: [
|
||||
ComplexStore,
|
||||
ComplexSessionService,
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRolesGuard,
|
||||
ChatGateway,
|
||||
],
|
||||
})
|
||||
export class ComplexAppModule {}
|
||||
1354
examples/demo-backend/src/apps/complex/complex.store.ts
Normal file
1354
examples/demo-backend/src/apps/complex/complex.store.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiNotModifiedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiOrganizationHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
type ComplexDemoRequest,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRoles,
|
||||
ComplexRolesGuard,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import {
|
||||
AdjustInventoryDto,
|
||||
BrandsResponseDto,
|
||||
ComplexCategoriesResponseDto,
|
||||
ComplexProductResponseDto,
|
||||
ComplexProductsResponseDto,
|
||||
CreateComplexProductDto,
|
||||
CursorProductQueryDto,
|
||||
InventoryItemResponseDto,
|
||||
InventoryResponseDto,
|
||||
UpdateComplexProductDto,
|
||||
WarehousesResponseDto,
|
||||
} from "../dto/catalog.dto";
|
||||
import { ComplexRole } from "../dto/identity.dto";
|
||||
|
||||
@ApiTags("Products")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("products")
|
||||
export class ComplexProductsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List tenant products using cursor pagination" })
|
||||
@ApiOkResponse({ type: ComplexProductsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query() query: CursorProductQueryDto,
|
||||
): ComplexProductsResponseDto {
|
||||
return this.store.listProducts(request.organizationId!, query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({
|
||||
summary: "Get a rich product with variants and ETag support",
|
||||
})
|
||||
@ApiOkResponse({ type: ComplexProductResponseDto })
|
||||
@ApiNotModifiedResponse()
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Headers("if-none-match") ifNoneMatch: string | undefined,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): ComplexProductResponseDto | undefined {
|
||||
const product = this.store.getProduct(request.organizationId!, id);
|
||||
const etag = `W/\"${product.id}-v${product.version}\"`;
|
||||
response.setHeader("ETag", etag);
|
||||
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
||||
if (etag === ifNoneMatch) {
|
||||
response.status(304);
|
||||
return undefined;
|
||||
}
|
||||
return { data: product };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Create a tenant product with variants" })
|
||||
@ApiCreatedResponse({ type: ComplexProductResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
create(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Body() dto: CreateComplexProductDto,
|
||||
): ComplexProductResponseDto {
|
||||
return {
|
||||
data: this.store.createProduct(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
dto,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Update a product using optimistic locking" })
|
||||
@ApiOkResponse({ type: ComplexProductResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
update(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateComplexProductDto,
|
||||
): ComplexProductResponseDto {
|
||||
return {
|
||||
data: this.store.updateProduct(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
dto,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Catalog")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller()
|
||||
export class ComplexCatalogController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get("categories")
|
||||
@ApiOperation({ summary: "List a recursive category tree" })
|
||||
@ApiOkResponse({ type: ComplexCategoriesResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
categories(@Req() request: ComplexDemoRequest): ComplexCategoriesResponseDto {
|
||||
return { data: this.store.listCategories(request.organizationId!) };
|
||||
}
|
||||
|
||||
@Get("brands")
|
||||
@ApiOperation({ summary: "List product brands" })
|
||||
@ApiOkResponse({ type: BrandsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
brands(@Req() request: ComplexDemoRequest): BrandsResponseDto {
|
||||
return { data: this.store.listBrands(request.organizationId!) };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Inventory")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller()
|
||||
export class ComplexInventoryController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get("warehouses")
|
||||
@ApiOperation({ summary: "List tenant warehouses" })
|
||||
@ApiOkResponse({ type: WarehousesResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
warehouses(@Req() request: ComplexDemoRequest): WarehousesResponseDto {
|
||||
return { data: this.store.listWarehouses(request.organizationId!) };
|
||||
}
|
||||
|
||||
@Get("inventory")
|
||||
@ApiOperation({ summary: "List inventory with optional product filter" })
|
||||
@ApiOkResponse({ type: InventoryResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
inventory(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query("productId") productId?: string,
|
||||
): InventoryResponseDto {
|
||||
return {
|
||||
data: this.store.listInventory(request.organizationId!, productId),
|
||||
};
|
||||
}
|
||||
|
||||
@Post("inventory/:id/adjust")
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Adjust stock using optimistic locking" })
|
||||
@ApiOkResponse({ type: InventoryItemResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
adjust(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: AdjustInventoryDto,
|
||||
): InventoryItemResponseDto {
|
||||
return {
|
||||
data: this.store.adjustInventory(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
dto,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiOrganizationHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import { ChatGateway } from "../chat.gateway";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
type ComplexDemoRequest,
|
||||
ComplexOrganizationGuard,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import {
|
||||
ConversationResponseDto,
|
||||
ConversationsResponseDto,
|
||||
MessageResponseDto,
|
||||
MessagesResponseDto,
|
||||
SendMessageDto,
|
||||
} from "../dto/chat.dto";
|
||||
import { CursorQueryDto } from "../dto/operations.dto";
|
||||
|
||||
@ApiTags("Chat")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("conversations")
|
||||
export class ChatController {
|
||||
constructor(
|
||||
private readonly store: ComplexStore,
|
||||
private readonly gateway: ChatGateway,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List conversations available to the current user" })
|
||||
@ApiOkResponse({ type: ConversationsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(@Req() request: ComplexDemoRequest): ConversationsResponseDto {
|
||||
return {
|
||||
data: this.store.listConversations(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one conversation" })
|
||||
@ApiOkResponse({ type: ConversationResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): ConversationResponseDto {
|
||||
return {
|
||||
data: this.store.getConversation(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id/messages")
|
||||
@ApiOperation({ summary: "Load message history using cursor pagination" })
|
||||
@ApiOkResponse({ type: MessagesResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
messages(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Query() query: CursorQueryDto,
|
||||
): MessagesResponseDto {
|
||||
return this.store.listMessages(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
query.cursor,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/messages")
|
||||
@HttpCode(200)
|
||||
@UseGuards(ComplexCsrfGuard)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send an idempotent message through REST and broadcast it over Socket.IO",
|
||||
})
|
||||
@ApiOkResponse({ type: MessageResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
send(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
): MessageResponseDto {
|
||||
const message = this.store.sendMessage(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
this.gateway.broadcastMessage(message);
|
||||
return { data: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiIdempotencyHeader,
|
||||
ApiOrganizationHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
type ComplexDemoRequest,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRoles,
|
||||
ComplexRolesGuard,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import {
|
||||
ComplexOrderResponseDto,
|
||||
ComplexOrdersResponseDto,
|
||||
CreateComplexOrderDto,
|
||||
CreateReviewDto,
|
||||
CustomerQueryDto,
|
||||
CustomerResponseDto,
|
||||
CustomersResponseDto,
|
||||
OrderCursorQueryDto,
|
||||
PaymentsResponseDto,
|
||||
PromotionsResponseDto,
|
||||
ReviewResponseDto,
|
||||
ReviewsResponseDto,
|
||||
} from "../dto/commerce.dto";
|
||||
import { ComplexRole } from "../dto/identity.dto";
|
||||
|
||||
@ApiTags("Customers")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("customers")
|
||||
export class CustomersController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List customers using offset pagination and search",
|
||||
})
|
||||
@ApiOkResponse({ type: CustomersResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query() query: CustomerQueryDto,
|
||||
): CustomersResponseDto {
|
||||
return this.store.listCustomers(request.organizationId!, query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one customer with nested address" })
|
||||
@ApiOkResponse({ type: CustomerResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): CustomerResponseDto {
|
||||
return { data: this.store.getCustomer(request.organizationId!, id) };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Orders")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("orders")
|
||||
export class ComplexOrdersController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List tenant orders using cursor pagination" })
|
||||
@ApiOkResponse({ type: ComplexOrdersResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query() query: OrderCursorQueryDto,
|
||||
): ComplexOrdersResponseDto {
|
||||
return this.store.listOrders(request.organizationId!, query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one order and its state" })
|
||||
@ApiOkResponse({ type: ComplexOrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): ComplexOrderResponseDto {
|
||||
return { data: this.store.getOrder(request.organizationId!, id) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiIdempotencyHeader()
|
||||
@ApiOperation({ summary: "Create an order idempotently" })
|
||||
@ApiCreatedResponse({ type: ComplexOrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, conflict: true })
|
||||
create(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Headers("idempotency-key") idempotencyKey: string,
|
||||
@Body() dto: CreateComplexOrderDto,
|
||||
): ComplexOrderResponseDto {
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException({
|
||||
code: "IDEMPOTENCY_KEY_REQUIRED",
|
||||
message: "Idempotency-Key header is required.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: this.store.createOrder(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
idempotencyKey,
|
||||
dto,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@HttpCode(200)
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Apply a validated order state transition" })
|
||||
@ApiOkResponse({ type: ComplexOrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
cancel(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): ComplexOrderResponseDto {
|
||||
return {
|
||||
data: this.store.cancelOrder(
|
||||
request.organizationId!,
|
||||
request.user!.id,
|
||||
id,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Payments and promotions")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller()
|
||||
export class PaymentsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "List payments for tenant orders" })
|
||||
@ApiOkResponse({ type: PaymentsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
payments(@Req() request: ComplexDemoRequest): PaymentsResponseDto {
|
||||
return { data: this.store.listPayments(request.organizationId!) };
|
||||
}
|
||||
|
||||
@Get("promotions")
|
||||
@ApiOperation({ summary: "List active and expired promotion contracts" })
|
||||
@ApiOkResponse({ type: PromotionsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
promotions(): PromotionsResponseDto {
|
||||
return { data: this.store.listPromotions() };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Reviews")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("reviews")
|
||||
export class ReviewsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List reviews with moderation state" })
|
||||
@ApiOkResponse({ type: ReviewsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query("productId") productId?: string,
|
||||
): ReviewsResponseDto {
|
||||
return { data: this.store.listReviews(request.organizationId!, productId) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(ComplexCsrfGuard)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Create a review in pending moderation state" })
|
||||
@ApiCreatedResponse({ type: ReviewResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
create(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Body() dto: CreateReviewDto,
|
||||
): ReviewResponseDto {
|
||||
return { data: this.store.createReview(request.organizationId!, dto) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiOrganizationHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import type { ComplexDemoRequest } from "../complex.auth";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRoles,
|
||||
ComplexRolesGuard,
|
||||
ComplexSessionService,
|
||||
CSRF_COOKIE,
|
||||
csrfCookieOptions,
|
||||
SESSION_COOKIE,
|
||||
sessionCookieOptions,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import {
|
||||
ComplexLoginDto,
|
||||
ComplexRole,
|
||||
ComplexUsersResponseDto,
|
||||
ComplexUserResponseDto,
|
||||
CookieSessionResponseDto,
|
||||
MembersResponseDto,
|
||||
OrganizationResponseDto,
|
||||
OrganizationsResponseDto,
|
||||
} from "../dto/identity.dto";
|
||||
|
||||
@ApiTags("Auth")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("auth")
|
||||
export class ComplexAuthController {
|
||||
constructor(private readonly sessions: ComplexSessionService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Login and establish HttpOnly cookie session" })
|
||||
@ApiOkResponse({
|
||||
type: CookieSessionResponseDto,
|
||||
headers: {
|
||||
"Set-Cookie": {
|
||||
description: "Sets demo_session (HttpOnly) and demo_csrf cookies.",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiStandardErrors()
|
||||
login(
|
||||
@Body() dto: ComplexLoginDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): CookieSessionResponseDto {
|
||||
const session = this.sessions.login(dto);
|
||||
this.setCookies(response, session.token, session.csrfToken);
|
||||
return this.sessions.response(session);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Rotate the current cookie session and CSRF token" })
|
||||
@ApiOkResponse({ type: CookieSessionResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
refresh(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): CookieSessionResponseDto {
|
||||
const session = this.sessions.rotate(request.sessionToken!);
|
||||
this.setCookies(response, session.token, session.csrfToken);
|
||||
return this.sessions.response(session);
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({
|
||||
summary: "Revoke cookie session and clear authentication cookies",
|
||||
})
|
||||
@ApiNoContentResponse()
|
||||
@ApiStandardErrors({ auth: true })
|
||||
logout(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): void {
|
||||
this.sessions.revoke(request.sessionToken);
|
||||
response.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||
response.clearCookie(CSRF_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
private setCookies(
|
||||
response: Response,
|
||||
token: string,
|
||||
csrfToken: string,
|
||||
): void {
|
||||
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
|
||||
response.cookie(CSRF_COOKIE, csrfToken, csrfCookieOptions());
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Users")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard)
|
||||
@Controller("users")
|
||||
export class ComplexUsersController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get("me")
|
||||
@ApiOperation({
|
||||
summary: "Get the authenticated user and available organizations",
|
||||
})
|
||||
@ApiOkResponse({ type: ComplexUserResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
me(@Req() request: ComplexDemoRequest): ComplexUserResponseDto {
|
||||
const user = this.store.findUserById(request.user!.id)!;
|
||||
return { data: this.store.publicUser(user) };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOrganizationHeader()
|
||||
@UseGuards(ComplexOrganizationGuard, ComplexRolesGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiOperation({ summary: "List users in the current tenant" })
|
||||
@ApiOkResponse({ type: ComplexUsersResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(@Req() request: ComplexDemoRequest): ComplexUsersResponseDto {
|
||||
return { data: this.store.listUsers(request.organizationId!) };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Organizations")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard)
|
||||
@Controller("organizations")
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List organizations available to the current user" })
|
||||
@ApiOkResponse({ type: OrganizationsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(@Req() request: ComplexDemoRequest): OrganizationsResponseDto {
|
||||
return { data: this.store.listOrganizations(request.user!.id) };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one available organization" })
|
||||
@ApiOkResponse({ type: OrganizationResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): OrganizationResponseDto {
|
||||
return { data: this.store.getOrganization(id, request.user!.id) };
|
||||
}
|
||||
|
||||
@Get(":id/members")
|
||||
@UseGuards(ComplexRolesGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiOperation({ summary: "List organization members and their roles" })
|
||||
@ApiOkResponse({ type: MembersResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
members(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): MembersResponseDto {
|
||||
this.store.getOrganization(id, request.user!.id);
|
||||
return { data: this.store.listMembers(id) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiAcceptedResponse,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiBinaryResponse,
|
||||
ApiDemoScenarioHeader,
|
||||
ApiOrganizationHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
type ComplexDemoRequest,
|
||||
ComplexOrganizationGuard,
|
||||
ComplexRoles,
|
||||
ComplexRolesGuard,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import { ComplexRole } from "../dto/identity.dto";
|
||||
import {
|
||||
AuditEventsResponseDto,
|
||||
AuditQueryDto,
|
||||
CursorQueryDto,
|
||||
FileResponseDto,
|
||||
FilesResponseDto,
|
||||
InventoryNotificationPayloadDto,
|
||||
JobResponseDto,
|
||||
NotificationResponseDto,
|
||||
NotificationsResponseDto,
|
||||
OrderNotificationPayloadDto,
|
||||
SystemNotificationPayloadDto,
|
||||
} from "../dto/operations.dto";
|
||||
|
||||
@ApiTags("Notifications")
|
||||
@ApiExtraModels(
|
||||
OrderNotificationPayloadDto,
|
||||
InventoryNotificationPayloadDto,
|
||||
SystemNotificationPayloadDto,
|
||||
)
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("notifications")
|
||||
export class NotificationsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List polymorphic notifications using cursor pagination",
|
||||
})
|
||||
@ApiOkResponse({ type: NotificationsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query() query: CursorQueryDto,
|
||||
): NotificationsResponseDto {
|
||||
return this.store.listNotifications(
|
||||
request.organizationId!,
|
||||
query.cursor,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/read")
|
||||
@HttpCode(200)
|
||||
@UseGuards(ComplexCsrfGuard)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Mark a notification as read" })
|
||||
@ApiOkResponse({ type: NotificationResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
markRead(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): NotificationResponseDto {
|
||||
return {
|
||||
data: this.store.markNotificationRead(request.organizationId!, id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Files")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List uploaded file metadata" })
|
||||
@ApiOkResponse({ type: FilesResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(@Req() request: ComplexDemoRequest): FilesResponseDto {
|
||||
return { data: this.store.listFiles(request.organizationId!) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }),
|
||||
)
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Upload a file up to 5 MiB and retain it in memory",
|
||||
})
|
||||
@ApiCreatedResponse({ type: FileResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
upload(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): FileResponseDto {
|
||||
if (!file)
|
||||
throw new BadRequestException({
|
||||
code: "FILE_REQUIRED",
|
||||
message: 'Multipart field "file" is required.',
|
||||
});
|
||||
return { data: this.store.addFile(request.organizationId!, file) };
|
||||
}
|
||||
|
||||
@Get(":id/download")
|
||||
@ApiProduces("application/octet-stream")
|
||||
@ApiOperation({ summary: "Download an in-memory file" })
|
||||
@ApiBinaryResponse("Binary file content.")
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
download(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): StreamableFile {
|
||||
const file = this.store.getFile(request.organizationId!, id);
|
||||
response.setHeader("Content-Type", file.metadata.mimeType);
|
||||
response.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${file.metadata.name.replace(/"/g, "")}"`,
|
||||
);
|
||||
return new StreamableFile(file.content);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Audit")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard, ComplexRolesGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@Controller("audit-events")
|
||||
export class AuditController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List immutable audit events using offset pagination",
|
||||
})
|
||||
@ApiOkResponse({ type: AuditEventsResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Query() query: AuditQueryDto,
|
||||
): AuditEventsResponseDto {
|
||||
return this.store.listAudit(request.organizationId!, query);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Background jobs")
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiOrganizationHeader()
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
|
||||
@Controller()
|
||||
export class JobsController {
|
||||
constructor(private readonly store: ComplexStore) {}
|
||||
|
||||
@Post("exports/orders")
|
||||
@HttpCode(202)
|
||||
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
|
||||
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({ summary: "Start an asynchronous orders export" })
|
||||
@ApiAcceptedResponse({ type: JobResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
startExport(@Req() request: ComplexDemoRequest): JobResponseDto {
|
||||
return { data: this.store.startOrdersExport(request.organizationId!) };
|
||||
}
|
||||
|
||||
@Get("jobs/:id")
|
||||
@ApiOperation({ summary: "Poll background job progress" })
|
||||
@ApiOkResponse({ type: JobResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
getJob(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
): JobResponseDto {
|
||||
return { data: this.store.getJob(request.organizationId!, id) };
|
||||
}
|
||||
|
||||
@Get("jobs/:id/result")
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOperation({
|
||||
summary: "Download a completed export; returns 409 while processing",
|
||||
})
|
||||
@ApiBinaryResponse("Generated orders CSV.", "text/csv")
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
result(
|
||||
@Req() request: ComplexDemoRequest,
|
||||
@Param("id") id: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): StreamableFile {
|
||||
const content = this.store.jobResult(request.organizationId!, id);
|
||||
response.setHeader("Content-Type", "text/csv");
|
||||
response.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="orders-${id}.csv"`,
|
||||
);
|
||||
return new StreamableFile(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../../common/api.decorators";
|
||||
import {
|
||||
HealthResponseDto,
|
||||
ScenariosResponseDto,
|
||||
TestingActionResponseDto,
|
||||
} from "../../../common/api.dto";
|
||||
import {
|
||||
ComplexCookieGuard,
|
||||
ComplexCsrfGuard,
|
||||
type ComplexDemoRequest,
|
||||
ComplexSessionService,
|
||||
} from "../complex.auth";
|
||||
import { ComplexStore } from "../complex.store";
|
||||
import {
|
||||
ChangeComplexRoleDto,
|
||||
ComplexUserResponseDto,
|
||||
} from "../dto/identity.dto";
|
||||
|
||||
@ApiTags("Health")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("health")
|
||||
export class ComplexHealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Check Complex API availability" })
|
||||
@ApiOkResponse({ type: HealthResponseDto })
|
||||
health(): HealthResponseDto {
|
||||
return {
|
||||
data: {
|
||||
application: "complex",
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "1.0.0",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Testing")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("testing")
|
||||
export class ComplexTestingController {
|
||||
constructor(
|
||||
private readonly store: ComplexStore,
|
||||
private readonly sessions: ComplexSessionService,
|
||||
) {}
|
||||
|
||||
@Get("scenarios")
|
||||
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
|
||||
@ApiOkResponse({ type: ScenariosResponseDto })
|
||||
scenarios(): ScenariosResponseDto {
|
||||
return {
|
||||
data: [
|
||||
{ name: "normal", description: "Default behavior." },
|
||||
{
|
||||
name: "slow",
|
||||
description:
|
||||
"Delays the response to exercise loading and cancellation states.",
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
description:
|
||||
"Delays long enough for a frontend timeout or cancellation.",
|
||||
},
|
||||
{
|
||||
name: "server-error",
|
||||
description: "Returns a deterministic 500 error.",
|
||||
},
|
||||
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
|
||||
{
|
||||
name: "empty",
|
||||
description: "Turns list responses into a valid empty state.",
|
||||
},
|
||||
{
|
||||
name: "expired-auth",
|
||||
description: "Makes protected routes return 401.",
|
||||
},
|
||||
{
|
||||
name: "forbidden",
|
||||
description: "Makes protected routes return 403.",
|
||||
},
|
||||
{ name: "conflict", description: "Makes mutation routes return 409." },
|
||||
{
|
||||
name: "large-dataset",
|
||||
description: "Expands list responses to 250 deterministic items.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Post("reset")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Reset all Complex API data and active sessions" })
|
||||
@ApiOkResponse({ type: TestingActionResponseDto })
|
||||
reset(): TestingActionResponseDto {
|
||||
this.store.reset("small");
|
||||
this.sessions.reset();
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message:
|
||||
"Complex API state and sessions reset to the default deterministic seed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("seed/:preset")
|
||||
@HttpCode(200)
|
||||
@ApiParam({ name: "preset", enum: ["small", "large"] })
|
||||
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
|
||||
@ApiOkResponse({ type: TestingActionResponseDto })
|
||||
seed(@Param("preset") preset: string): TestingActionResponseDto {
|
||||
if (preset !== "small" && preset !== "large") {
|
||||
throw new BadRequestException({
|
||||
code: "UNKNOWN_SEED_PRESET",
|
||||
message: "Preset must be small or large.",
|
||||
});
|
||||
}
|
||||
this.store.reset(preset);
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message: `Complex API loaded the ${preset} dataset.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("session/expire")
|
||||
@HttpCode(200)
|
||||
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
|
||||
@ApiCookieAuth("cookieSession")
|
||||
@ApiSecurity("csrf")
|
||||
@ApiOperation({
|
||||
summary: "Expire the current cookie session after this response",
|
||||
})
|
||||
@ApiOkResponse({ type: TestingActionResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
expireSession(@Req() request: ComplexDemoRequest): TestingActionResponseDto {
|
||||
this.sessions.expire(request.sessionToken);
|
||||
return {
|
||||
data: { success: true, message: "Current cookie session expired." },
|
||||
};
|
||||
}
|
||||
|
||||
@Post("users/:userId/role")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Change a role while sessions remain active" })
|
||||
@ApiOkResponse({ type: ComplexUserResponseDto })
|
||||
@ApiStandardErrors({ notFound: true })
|
||||
changeRole(
|
||||
@Param("userId") userId: string,
|
||||
@Body() dto: ChangeComplexRoleDto,
|
||||
): ComplexUserResponseDto {
|
||||
return { data: this.store.changeUserRole(userId, dto.role) };
|
||||
}
|
||||
}
|
||||
329
examples/demo-backend/src/apps/complex/dto/catalog.dto.ts
Normal file
329
examples/demo-backend/src/apps/complex/dto/catalog.dto.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { CursorMetaDto } from "../../../common/api.dto";
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty({
|
||||
example: "129.90",
|
||||
pattern: "^\\d+\\.\\d{2}$",
|
||||
description:
|
||||
"Decimal string; never parse money as a floating-point number.",
|
||||
})
|
||||
amount!: string;
|
||||
|
||||
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
|
||||
currency!: "USD" | "EUR";
|
||||
}
|
||||
|
||||
export enum ComplexProductStatus {
|
||||
Draft = "draft",
|
||||
Active = "active",
|
||||
Archived = "archived",
|
||||
}
|
||||
|
||||
export class ProductVariantDto {
|
||||
@ApiProperty({ example: "variant-keyboard-black" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "KEYBOARD-BLACK-US" })
|
||||
sku!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { color: "black", layout: "US" },
|
||||
})
|
||||
attributes!: Record<string, string>;
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
price!: MoneyDto;
|
||||
}
|
||||
|
||||
export class ComplexProductDto {
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Pro Mechanical Keyboard" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "pro-mechanical-keyboard" })
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ enum: ComplexProductStatus })
|
||||
status!: ComplexProductStatus;
|
||||
|
||||
@ApiProperty({ example: "complex-category-electronics" })
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ example: "brand-northstar" })
|
||||
brandId!: string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
price!: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: [ProductVariantDto] })
|
||||
variants!: ProductVariantDto[];
|
||||
|
||||
@ApiProperty({ type: [String], example: ["featured", "office"] })
|
||||
tags!: string[];
|
||||
|
||||
@ApiProperty({ format: "date-time", nullable: true })
|
||||
publishedAt!: string | null;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ example: 3, description: "Optimistic-lock version." })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class CursorProductQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "complex-product-mouse" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "keyboard" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ComplexProductStatus })
|
||||
@IsEnum(ComplexProductStatus)
|
||||
@IsOptional()
|
||||
status?: ComplexProductStatus;
|
||||
}
|
||||
|
||||
export class ComplexProductsResponseDto {
|
||||
@ApiProperty({ type: [ComplexProductDto] })
|
||||
data!: ComplexProductDto[];
|
||||
|
||||
@ApiProperty({ type: CursorMetaDto })
|
||||
meta!: CursorMetaDto;
|
||||
}
|
||||
|
||||
export class ComplexProductResponseDto {
|
||||
@ApiProperty({ type: ComplexProductDto })
|
||||
data!: ComplexProductDto;
|
||||
}
|
||||
|
||||
export class CreateVariantDto {
|
||||
@ApiProperty({ example: "KEYBOARD-WHITE-US" })
|
||||
@IsString()
|
||||
sku!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { color: "white", layout: "US" },
|
||||
})
|
||||
@IsObject()
|
||||
attributes!: Record<string, string>;
|
||||
|
||||
@ApiProperty({ example: "139.90" })
|
||||
@IsString()
|
||||
priceAmount!: string;
|
||||
}
|
||||
|
||||
export class CreateComplexProductDto {
|
||||
@ApiProperty({ example: "Pro Mechanical Keyboard" })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(2000)
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ComplexProductStatus,
|
||||
default: ComplexProductStatus.Draft,
|
||||
})
|
||||
@IsEnum(ComplexProductStatus)
|
||||
status!: ComplexProductStatus;
|
||||
|
||||
@ApiProperty({ example: "complex-category-electronics" })
|
||||
@IsString()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ example: "brand-northstar" })
|
||||
@IsString()
|
||||
brandId!: string;
|
||||
|
||||
@ApiProperty({ example: "129.90" })
|
||||
@IsString()
|
||||
priceAmount!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateVariantDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateVariantDto)
|
||||
variants!: CreateVariantDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], example: ["featured"] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags: string[] = [];
|
||||
}
|
||||
|
||||
export class UpdateComplexProductDto extends PartialType(
|
||||
CreateComplexProductDto,
|
||||
) {
|
||||
@ApiProperty({
|
||||
minimum: 1,
|
||||
example: 3,
|
||||
description: "Version last read by the frontend.",
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class ComplexCategoryDto {
|
||||
@ApiProperty({ example: "complex-category-electronics" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Electronics" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ nullable: true, example: null })
|
||||
parentId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [String], example: ["complex-category-keyboards"] })
|
||||
childIds!: string[];
|
||||
}
|
||||
|
||||
export class ComplexCategoriesResponseDto {
|
||||
@ApiProperty({ type: [ComplexCategoryDto] })
|
||||
data!: ComplexCategoryDto[];
|
||||
}
|
||||
|
||||
export class BrandDto {
|
||||
@ApiProperty({ example: "brand-northstar" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Northstar" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ format: "uri", nullable: true })
|
||||
logoUrl!: string | null;
|
||||
}
|
||||
|
||||
export class BrandsResponseDto {
|
||||
@ApiProperty({ type: [BrandDto] })
|
||||
data!: BrandDto[];
|
||||
}
|
||||
|
||||
export class WarehouseDto {
|
||||
@ApiProperty({ example: "warehouse-berlin" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Berlin Warehouse" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "DE" })
|
||||
countryCode!: string;
|
||||
|
||||
@ApiProperty({ enum: ["active", "maintenance"], example: "active" })
|
||||
status!: "active" | "maintenance";
|
||||
}
|
||||
|
||||
export class WarehousesResponseDto {
|
||||
@ApiProperty({ type: [WarehouseDto] })
|
||||
data!: WarehouseDto[];
|
||||
}
|
||||
|
||||
export class InventoryItemDto {
|
||||
@ApiProperty({ example: "inventory-keyboard-berlin" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "variant-keyboard-black" })
|
||||
variantId!: string;
|
||||
|
||||
@ApiProperty({ example: "warehouse-berlin" })
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ example: 42 })
|
||||
available!: number;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
reserved!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
reorderPoint!: number;
|
||||
|
||||
@ApiProperty({ example: 2 })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class InventoryResponseDto {
|
||||
@ApiProperty({ type: [InventoryItemDto] })
|
||||
data!: InventoryItemDto[];
|
||||
}
|
||||
|
||||
export class InventoryItemResponseDto {
|
||||
@ApiProperty({ type: InventoryItemDto })
|
||||
data!: InventoryItemDto;
|
||||
}
|
||||
|
||||
export class AdjustInventoryDto {
|
||||
@ApiProperty({ example: -2, description: "Signed stock adjustment." })
|
||||
@IsInt()
|
||||
delta!: number;
|
||||
|
||||
@ApiProperty({ example: "Damaged during delivery" })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
reason!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 2,
|
||||
description: "Version last read by the frontend.",
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
version!: number;
|
||||
}
|
||||
87
examples/demo-backend/src/apps/complex/dto/chat.dto.ts
Normal file
87
examples/demo-backend/src/apps/complex/dto/chat.dto.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { CursorMetaDto } from "../../../common/api.dto";
|
||||
|
||||
export class ConversationDto {
|
||||
@ApiProperty({ example: "conversation-support" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Order support" })
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ["complex-user-admin", "complex-user-support"],
|
||||
})
|
||||
participantIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
example: "Can you check order complex-order-001?",
|
||||
})
|
||||
lastMessagePreview!: string | null;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
export class ConversationsResponseDto {
|
||||
@ApiProperty({ type: [ConversationDto] })
|
||||
data!: ConversationDto[];
|
||||
}
|
||||
|
||||
export class ConversationResponseDto {
|
||||
@ApiProperty({ type: ConversationDto })
|
||||
data!: ConversationDto;
|
||||
}
|
||||
|
||||
export class ChatMessageDto {
|
||||
@ApiProperty({ example: "message-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "conversation-support" })
|
||||
conversationId!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-user-support" })
|
||||
senderId!: string;
|
||||
|
||||
@ApiProperty({ example: "The order has already been packed." })
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "client-message-4c08",
|
||||
description: "Frontend-generated key used to deduplicate retries.",
|
||||
})
|
||||
clientMessageId!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class MessagesResponseDto {
|
||||
@ApiProperty({ type: [ChatMessageDto] })
|
||||
data!: ChatMessageDto[];
|
||||
|
||||
@ApiProperty({ type: CursorMetaDto })
|
||||
meta!: CursorMetaDto;
|
||||
}
|
||||
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ example: "The order has already been packed." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({ example: "client-message-4c08" })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
clientMessageId!: string;
|
||||
}
|
||||
|
||||
export class MessageResponseDto {
|
||||
@ApiProperty({ type: ChatMessageDto })
|
||||
data!: ChatMessageDto;
|
||||
}
|
||||
324
examples/demo-backend/src/apps/complex/dto/commerce.dto.ts
Normal file
324
examples/demo-backend/src/apps/complex/dto/commerce.dto.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
|
||||
import { MoneyDto } from "./catalog.dto";
|
||||
|
||||
export class AddressDto {
|
||||
@ApiProperty({ example: "Friedrichstrasse 100" })
|
||||
line1!: string;
|
||||
|
||||
@ApiProperty({ nullable: true, example: null })
|
||||
line2!: string | null;
|
||||
|
||||
@ApiProperty({ example: "Berlin" })
|
||||
city!: string;
|
||||
|
||||
@ApiProperty({ example: "10117" })
|
||||
postalCode!: string;
|
||||
|
||||
@ApiProperty({ example: "DE" })
|
||||
countryCode!: string;
|
||||
}
|
||||
|
||||
export class CustomerDto {
|
||||
@ApiProperty({ example: "customer-ada" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "Ada Lovelace" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ format: "email", example: "ada@example.test" })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ type: AddressDto })
|
||||
defaultAddress!: AddressDto;
|
||||
|
||||
@ApiProperty({ type: [String], example: ["vip", "newsletter"] })
|
||||
tags!: string[];
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class CustomerQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "ada" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class CustomersResponseDto {
|
||||
@ApiProperty({ type: [CustomerDto] })
|
||||
data!: CustomerDto[];
|
||||
|
||||
@ApiProperty({ type: PageMetaDto })
|
||||
meta!: PageMetaDto;
|
||||
}
|
||||
|
||||
export class CustomerResponseDto {
|
||||
@ApiProperty({ type: CustomerDto })
|
||||
data!: CustomerDto;
|
||||
}
|
||||
|
||||
export enum ComplexOrderStatus {
|
||||
Draft = "draft",
|
||||
AwaitingPayment = "awaiting-payment",
|
||||
Paid = "paid",
|
||||
Fulfillment = "fulfillment",
|
||||
Shipped = "shipped",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export class ComplexOrderItemDto {
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "variant-keyboard-black" })
|
||||
variantId!: string;
|
||||
|
||||
@ApiProperty({ example: "Pro Mechanical Keyboard" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
unitPrice!: MoneyDto;
|
||||
}
|
||||
|
||||
export class ComplexOrderDto {
|
||||
@ApiProperty({ example: "complex-order-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "customer-ada" })
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ enum: ComplexOrderStatus })
|
||||
status!: ComplexOrderStatus;
|
||||
|
||||
@ApiProperty({ type: [ComplexOrderItemDto] })
|
||||
items!: ComplexOrderItemDto[];
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
subtotal!: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
discount!: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
total!: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: AddressDto })
|
||||
shippingAddress!: AddressDto;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class OrderCursorQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "complex-order-001" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ComplexOrderStatus })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: ComplexOrderStatus;
|
||||
}
|
||||
|
||||
export class ComplexOrdersResponseDto {
|
||||
@ApiProperty({ type: [ComplexOrderDto] })
|
||||
data!: ComplexOrderDto[];
|
||||
|
||||
@ApiProperty({ type: CursorMetaDto })
|
||||
meta!: CursorMetaDto;
|
||||
}
|
||||
|
||||
export class ComplexOrderResponseDto {
|
||||
@ApiProperty({ type: ComplexOrderDto })
|
||||
data!: ComplexOrderDto;
|
||||
}
|
||||
|
||||
export class CreateComplexOrderItemDto {
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "variant-keyboard-black" })
|
||||
@IsString()
|
||||
variantId!: string;
|
||||
|
||||
@ApiProperty({ example: 1, minimum: 1, maximum: 50 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateComplexOrderDto {
|
||||
@ApiProperty({ example: "customer-ada" })
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateComplexOrderItemDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateComplexOrderItemDto)
|
||||
items!: CreateComplexOrderItemDto[];
|
||||
|
||||
@ApiPropertyOptional({ example: "WELCOME10" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
promotionCode?: string;
|
||||
}
|
||||
|
||||
export class PaymentDto {
|
||||
@ApiProperty({ example: "payment-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-order-001" })
|
||||
orderId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ["pending", "succeeded", "failed", "refunded"],
|
||||
example: "succeeded",
|
||||
})
|
||||
status!: "pending" | "succeeded" | "failed" | "refunded";
|
||||
|
||||
@ApiProperty({ enum: ["card", "bank-transfer"], example: "card" })
|
||||
method!: "card" | "bank-transfer";
|
||||
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
amount!: MoneyDto;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class PaymentsResponseDto {
|
||||
@ApiProperty({ type: [PaymentDto] })
|
||||
data!: PaymentDto[];
|
||||
}
|
||||
|
||||
export class PromotionDto {
|
||||
@ApiProperty({ example: "promotion-welcome" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "WELCOME10" })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: ["percentage", "fixed"], example: "percentage" })
|
||||
type!: "percentage" | "fixed";
|
||||
|
||||
@ApiProperty({ example: "10.00" })
|
||||
value!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
validUntil!: string;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
active!: boolean;
|
||||
}
|
||||
|
||||
export class PromotionsResponseDto {
|
||||
@ApiProperty({ type: [PromotionDto] })
|
||||
data!: PromotionDto[];
|
||||
}
|
||||
|
||||
export class ReviewDto {
|
||||
@ApiProperty({ example: "review-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "customer-ada" })
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ example: 5, minimum: 1, maximum: 5 })
|
||||
rating!: number;
|
||||
|
||||
@ApiProperty({ example: "Excellent keyboard for daily development." })
|
||||
comment!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ["pending", "published", "rejected"],
|
||||
example: "published",
|
||||
})
|
||||
status!: "pending" | "published" | "rejected";
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class ReviewsResponseDto {
|
||||
@ApiProperty({ type: [ReviewDto] })
|
||||
data!: ReviewDto[];
|
||||
}
|
||||
|
||||
export class CreateReviewDto {
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "customer-ada" })
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ minimum: 1, maximum: 5, example: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating!: number;
|
||||
|
||||
@ApiProperty({ example: "Excellent keyboard for daily development." })
|
||||
@IsString()
|
||||
comment!: string;
|
||||
}
|
||||
|
||||
export class ReviewResponseDto {
|
||||
@ApiProperty({ type: ReviewDto })
|
||||
data!: ReviewDto;
|
||||
}
|
||||
139
examples/demo-backend/src/apps/complex/dto/identity.dto.ts
Normal file
139
examples/demo-backend/src/apps/complex/dto/identity.dto.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { IsEmail, IsEnum, IsString, MinLength } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export enum ComplexRole {
|
||||
Admin = "admin",
|
||||
Manager = "manager",
|
||||
Support = "support",
|
||||
Viewer = "viewer",
|
||||
}
|
||||
|
||||
export class ComplexLoginDto {
|
||||
@ApiProperty({ format: "email", example: "admin@complex.demo" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class ComplexUserDto {
|
||||
@ApiProperty({ example: "complex-user-admin" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: "email", example: "admin@complex.demo" })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: "Complex Admin" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: ComplexRole })
|
||||
role!: ComplexRole;
|
||||
|
||||
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=20" })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@ApiProperty({ type: [String], example: ["org-acme", "org-globex"] })
|
||||
organizationIds!: string[];
|
||||
}
|
||||
|
||||
export class ComplexUserResponseDto {
|
||||
@ApiProperty({ type: ComplexUserDto })
|
||||
data!: ComplexUserDto;
|
||||
}
|
||||
|
||||
export class ComplexUsersResponseDto {
|
||||
@ApiProperty({ type: [ComplexUserDto] })
|
||||
data!: ComplexUserDto[];
|
||||
}
|
||||
|
||||
export class CookieSessionDataDto {
|
||||
@ApiProperty({ type: ComplexUserDto })
|
||||
user!: ComplexUserDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: "5f2642ca-2ba4-4ee3-bf2e-d5d37a97686c",
|
||||
description: "Send this value in X-CSRF-Token for authenticated mutations.",
|
||||
})
|
||||
csrfToken!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
expiresAt!: string;
|
||||
}
|
||||
|
||||
export class CookieSessionResponseDto {
|
||||
@ApiProperty({ type: CookieSessionDataDto })
|
||||
data!: CookieSessionDataDto;
|
||||
}
|
||||
|
||||
export enum OrganizationPlan {
|
||||
Starter = "starter",
|
||||
Business = "business",
|
||||
Enterprise = "enterprise",
|
||||
}
|
||||
|
||||
export class OrganizationDto {
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "Acme Commerce" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: OrganizationPlan })
|
||||
plan!: OrganizationPlan;
|
||||
|
||||
@ApiProperty({ example: "Europe/Berlin" })
|
||||
timezone!: string;
|
||||
|
||||
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
|
||||
currency!: "USD" | "EUR";
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class OrganizationsResponseDto {
|
||||
@ApiProperty({ type: [OrganizationDto] })
|
||||
data!: OrganizationDto[];
|
||||
}
|
||||
|
||||
export class OrganizationResponseDto {
|
||||
@ApiProperty({ type: OrganizationDto })
|
||||
data!: OrganizationDto;
|
||||
}
|
||||
|
||||
export class MemberDto {
|
||||
@ApiProperty({ example: "member-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "org-acme" })
|
||||
organizationId!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-user-manager" })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ example: "Complex Manager" })
|
||||
userName!: string;
|
||||
|
||||
@ApiProperty({ format: "email", example: "manager@complex.demo" })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ enum: ComplexRole })
|
||||
role!: ComplexRole;
|
||||
|
||||
@ApiProperty({ enum: ["active", "invited", "suspended"], example: "active" })
|
||||
status!: "active" | "invited" | "suspended";
|
||||
}
|
||||
|
||||
export class MembersResponseDto {
|
||||
@ApiProperty({ type: [MemberDto] })
|
||||
data!: MemberDto[];
|
||||
}
|
||||
|
||||
export class ChangeComplexRoleDto {
|
||||
@ApiProperty({ enum: ComplexRole, example: ComplexRole.Viewer })
|
||||
@IsEnum(ComplexRole)
|
||||
role!: ComplexRole;
|
||||
}
|
||||
244
examples/demo-backend/src/apps/complex/dto/operations.dto.ts
Normal file
244
examples/demo-backend/src/apps/complex/dto/operations.dto.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
getSchemaPath,
|
||||
} from "@nestjs/swagger";
|
||||
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
|
||||
|
||||
export class OrderNotificationPayloadDto {
|
||||
@ApiProperty({ enum: ["order"], example: "order" })
|
||||
type!: "order";
|
||||
|
||||
@ApiProperty({ example: "complex-order-001" })
|
||||
orderId!: string;
|
||||
|
||||
@ApiProperty({ enum: ["paid", "shipped", "cancelled"], example: "shipped" })
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class InventoryNotificationPayloadDto {
|
||||
@ApiProperty({ enum: ["inventory"], example: "inventory" })
|
||||
type!: "inventory";
|
||||
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: 4 })
|
||||
remaining!: number;
|
||||
}
|
||||
|
||||
export class SystemNotificationPayloadDto {
|
||||
@ApiProperty({ enum: ["system"], example: "system" })
|
||||
type!: "system";
|
||||
|
||||
@ApiProperty({ example: "Scheduled maintenance begins at 02:00 UTC." })
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export enum NotificationKind {
|
||||
Order = "order",
|
||||
Inventory = "inventory",
|
||||
System = "system",
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
OrderNotificationPayloadDto,
|
||||
InventoryNotificationPayloadDto,
|
||||
SystemNotificationPayloadDto,
|
||||
)
|
||||
export class NotificationDto {
|
||||
@ApiProperty({ example: "notification-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: NotificationKind })
|
||||
kind!: NotificationKind;
|
||||
|
||||
@ApiProperty({ example: "Order shipped" })
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OrderNotificationPayloadDto) },
|
||||
{ $ref: getSchemaPath(InventoryNotificationPayloadDto) },
|
||||
{ $ref: getSchemaPath(SystemNotificationPayloadDto) },
|
||||
],
|
||||
discriminator: {
|
||||
propertyName: "type",
|
||||
mapping: {
|
||||
order: getSchemaPath(OrderNotificationPayloadDto),
|
||||
inventory: getSchemaPath(InventoryNotificationPayloadDto),
|
||||
system: getSchemaPath(SystemNotificationPayloadDto),
|
||||
},
|
||||
},
|
||||
})
|
||||
payload!:
|
||||
| OrderNotificationPayloadDto
|
||||
| InventoryNotificationPayloadDto
|
||||
| SystemNotificationPayloadDto;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
read!: boolean;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class NotificationsResponseDto {
|
||||
@ApiProperty({ type: [NotificationDto] })
|
||||
data!: NotificationDto[];
|
||||
|
||||
@ApiProperty({ type: CursorMetaDto })
|
||||
meta!: CursorMetaDto;
|
||||
}
|
||||
|
||||
export class NotificationResponseDto {
|
||||
@ApiProperty({ type: NotificationDto })
|
||||
data!: NotificationDto;
|
||||
}
|
||||
|
||||
export class CursorQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "notification-020" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export class FileMetadataDto {
|
||||
@ApiProperty({ example: "file-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "products.csv" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "text/csv" })
|
||||
mimeType!: string;
|
||||
|
||||
@ApiProperty({ example: 18432 })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ format: "uri", example: "/api/v1/files/file-001/download" })
|
||||
downloadUrl!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class FileResponseDto {
|
||||
@ApiProperty({ type: FileMetadataDto })
|
||||
data!: FileMetadataDto;
|
||||
}
|
||||
|
||||
export class FilesResponseDto {
|
||||
@ApiProperty({ type: [FileMetadataDto] })
|
||||
data!: FileMetadataDto[];
|
||||
}
|
||||
|
||||
export class AuditEventDto {
|
||||
@ApiProperty({ example: "audit-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "product.updated" })
|
||||
action!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-user-admin" })
|
||||
actorId!: string;
|
||||
|
||||
@ApiProperty({ example: "product" })
|
||||
resourceType!: string;
|
||||
|
||||
@ApiProperty({ example: "complex-product-keyboard" })
|
||||
resourceId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
example: { version: 4 },
|
||||
})
|
||||
metadata!: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class AuditQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "product.updated" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export class AuditEventsResponseDto {
|
||||
@ApiProperty({ type: [AuditEventDto] })
|
||||
data!: AuditEventDto[];
|
||||
|
||||
@ApiProperty({ type: PageMetaDto })
|
||||
meta!: PageMetaDto;
|
||||
}
|
||||
|
||||
export enum JobStatus {
|
||||
Pending = "pending",
|
||||
Processing = "processing",
|
||||
Completed = "completed",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export class JobDto {
|
||||
@ApiProperty({ example: "job-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: ["orders-export"], example: "orders-export" })
|
||||
type!: "orders-export";
|
||||
|
||||
@ApiProperty({ enum: JobStatus })
|
||||
status!: JobStatus;
|
||||
|
||||
@ApiProperty({ example: 75, minimum: 0, maximum: 100 })
|
||||
progress!: number;
|
||||
|
||||
@ApiProperty({
|
||||
format: "uri",
|
||||
nullable: true,
|
||||
example: "/api/v1/jobs/job-001/result",
|
||||
})
|
||||
resultUrl!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, example: null })
|
||||
error!: string | null;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
export class JobResponseDto {
|
||||
@ApiProperty({ type: JobDto })
|
||||
data!: JobDto;
|
||||
}
|
||||
12
examples/demo-backend/src/apps/complex/main.ts
Normal file
12
examples/demo-backend/src/apps/complex/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createComplexApplication } from "./bootstrap";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const { app } = await createComplexApplication();
|
||||
const port = Number(process.env.COMPLEX_PORT ?? 3002);
|
||||
await app.listen(port);
|
||||
console.log(`Complex API: http://localhost:${port}/api/v1`);
|
||||
console.log(`Complex Swagger: http://localhost:${port}/docs`);
|
||||
console.log(`Complex Socket.IO namespace: ws://localhost:${port}/chat`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
20
examples/demo-backend/src/apps/simple/bootstrap.ts
Normal file
20
examples/demo-backend/src/apps/simple/bootstrap.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { OpenAPIObject } from "@nestjs/swagger";
|
||||
import { configureApplication } from "../../common/configure-application";
|
||||
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
|
||||
import { SimpleAppModule } from "./simple.module";
|
||||
|
||||
export async function createSimpleApplication(
|
||||
logger: false | undefined = undefined,
|
||||
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
SimpleAppModule,
|
||||
{ logger },
|
||||
);
|
||||
configureApplication(app);
|
||||
const port = Number(process.env.SIMPLE_PORT ?? 3001);
|
||||
const document = createOpenApiDocument(app, { kind: "simple", port });
|
||||
mountOpenApi(app, document, "Demo Simple API");
|
||||
return { app, document };
|
||||
}
|
||||
11
examples/demo-backend/src/apps/simple/main.ts
Normal file
11
examples/demo-backend/src/apps/simple/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createSimpleApplication } from "./bootstrap";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const { app } = await createSimpleApplication();
|
||||
const port = Number(process.env.SIMPLE_PORT ?? 3001);
|
||||
await app.listen(port);
|
||||
console.log(`Simple API: http://localhost:${port}/api/v1`);
|
||||
console.log(`Simple Swagger: http://localhost:${port}/docs`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
197
examples/demo-backend/src/apps/simple/simple.auth.ts
Normal file
197
examples/demo-backend/src/apps/simple/simple.auth.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import type { Request } from "express";
|
||||
import type { DemoRequest } from "../../common/request.types";
|
||||
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
|
||||
import {
|
||||
type JwtAuthResponseDto,
|
||||
type JwtTokensDto,
|
||||
type LoginDto,
|
||||
type RefreshTokenDto,
|
||||
SimpleRole,
|
||||
} from "./simple.dto";
|
||||
import { SimpleStore } from "./simple.store";
|
||||
|
||||
interface DemoJwtPayload {
|
||||
sub: string;
|
||||
type: "access" | "refresh";
|
||||
jti: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimpleAuthService {
|
||||
private readonly revokedRefreshTokens = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly store: SimpleStore,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<JwtAuthResponseDto> {
|
||||
const user = this.store.findUserByEmail(dto.email);
|
||||
if (!user || user.password !== dto.password) {
|
||||
throw new UnauthorizedException({
|
||||
code: "INVALID_CREDENTIALS",
|
||||
message: "Email or password is incorrect.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
tokens: await this.issueTokens(user.id),
|
||||
user: this.store.publicUser(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
|
||||
let payload: DemoJwtPayload;
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
|
||||
dto.refreshToken,
|
||||
{ secret: this.refreshSecret },
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
code: "INVALID_REFRESH_TOKEN",
|
||||
message: "Refresh token is invalid or expired.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
payload.type !== "refresh" ||
|
||||
this.revokedRefreshTokens.has(payload.jti)
|
||||
) {
|
||||
throw new UnauthorizedException({
|
||||
code: "REFRESH_TOKEN_REUSED",
|
||||
message: "Refresh token was revoked or already used.",
|
||||
});
|
||||
}
|
||||
const user = this.store.findUserById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: "USER_NOT_FOUND",
|
||||
message: "Token user no longer exists.",
|
||||
});
|
||||
}
|
||||
this.revokedRefreshTokens.add(payload.jti);
|
||||
return {
|
||||
data: {
|
||||
tokens: await this.issueTokens(user.id),
|
||||
user: this.store.publicUser(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async logout(dto: RefreshTokenDto): Promise<void> {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
|
||||
dto.refreshToken,
|
||||
{ secret: this.refreshSecret },
|
||||
);
|
||||
this.revokedRefreshTokens.add(payload.jti);
|
||||
} catch {
|
||||
// Logout remains idempotent even when the token has already expired.
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.revokedRefreshTokens.clear();
|
||||
}
|
||||
|
||||
private async issueTokens(userId: string): Promise<JwtTokensDto> {
|
||||
const accessJti = randomUUID();
|
||||
const refreshJti = randomUUID();
|
||||
const accessTtl = process.env.JWT_ACCESS_TTL ?? "60s";
|
||||
const refreshTtl = process.env.JWT_REFRESH_TTL ?? "7d";
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{ sub: userId, type: "access", jti: accessJti },
|
||||
{ secret: this.accessSecret, expiresIn: accessTtl as never },
|
||||
),
|
||||
this.jwtService.signAsync(
|
||||
{ sub: userId, type: "refresh", jti: refreshJti },
|
||||
{ secret: this.refreshSecret, expiresIn: refreshTtl as never },
|
||||
),
|
||||
]);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: this.accessTtlSeconds(accessTtl),
|
||||
tokenType: "Bearer",
|
||||
};
|
||||
}
|
||||
|
||||
private accessTtlSeconds(value: string): number {
|
||||
const match = /^(\d+)([smhd])$/.exec(value);
|
||||
if (!match) return 60;
|
||||
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
||||
return Number(match[1]) * multipliers[match[2] as keyof typeof multipliers];
|
||||
}
|
||||
|
||||
private get accessSecret(): string {
|
||||
return process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me";
|
||||
}
|
||||
|
||||
private get refreshSecret(): string {
|
||||
return process.env.JWT_REFRESH_SECRET ?? "demo-refresh-secret-change-me";
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimpleJwtGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly store: SimpleStore,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>() as DemoRequest;
|
||||
assertNoForcedAuthFailure(request);
|
||||
const authorization = request.headers.authorization;
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException({
|
||||
code: "JWT_MISSING",
|
||||
message: "Bearer access token is required.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
|
||||
authorization.slice(7),
|
||||
{
|
||||
secret:
|
||||
process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me",
|
||||
},
|
||||
);
|
||||
if (payload.type !== "access") throw new Error("Wrong token type");
|
||||
const user = this.store.findUserById(payload.sub);
|
||||
if (!user) throw new Error("Unknown user");
|
||||
request.user = this.store.publicUser(user);
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
code: "JWT_INVALID",
|
||||
message: "Access token is invalid or expired.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimpleAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<DemoRequest>();
|
||||
if (request.user?.role !== SimpleRole.Admin) {
|
||||
throw new ForbiddenException({
|
||||
code: "ADMIN_REQUIRED",
|
||||
message: "Administrator role is required.",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
399
examples/demo-backend/src/apps/simple/simple.controllers.ts
Normal file
399
examples/demo-backend/src/apps/simple/simple.controllers.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotModifiedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiDemoScenarioHeader,
|
||||
ApiStandardErrors,
|
||||
} from "../../common/api.decorators";
|
||||
import {
|
||||
HealthResponseDto,
|
||||
MutationResponseDto,
|
||||
ScenariosResponseDto,
|
||||
TestingActionResponseDto,
|
||||
} from "../../common/api.dto";
|
||||
import type { DemoRequest } from "../../common/request.types";
|
||||
import {
|
||||
SimpleAdminGuard,
|
||||
SimpleAuthService,
|
||||
SimpleJwtGuard,
|
||||
} from "./simple.auth";
|
||||
import {
|
||||
CategoriesResponseDto,
|
||||
CategoryResponseDto,
|
||||
ChangeSimpleRoleDto,
|
||||
CreateOrderDto,
|
||||
CreateProductDto,
|
||||
JwtAuthResponseDto,
|
||||
LoginDto,
|
||||
OrderResponseDto,
|
||||
OrdersResponseDto,
|
||||
PageQueryDto,
|
||||
ProductQueryDto,
|
||||
ProductResponseDto,
|
||||
ProductsResponseDto,
|
||||
RefreshTokenDto,
|
||||
SimpleRole,
|
||||
SimpleUserResponseDto,
|
||||
UpdateProductDto,
|
||||
} from "./simple.dto";
|
||||
import { SimpleStore } from "./simple.store";
|
||||
|
||||
@ApiTags("Health")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("health")
|
||||
export class SimpleHealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Check Simple API availability" })
|
||||
@ApiOkResponse({ type: HealthResponseDto })
|
||||
health(): HealthResponseDto {
|
||||
return {
|
||||
data: {
|
||||
application: "simple",
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "1.0.0",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Auth")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("auth")
|
||||
export class SimpleAuthController {
|
||||
constructor(private readonly auth: SimpleAuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Login and receive JWT access/refresh tokens" })
|
||||
@ApiOkResponse({ type: JwtAuthResponseDto })
|
||||
@ApiStandardErrors()
|
||||
login(@Body() dto: LoginDto): Promise<JwtAuthResponseDto> {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: "Rotate a refresh token and issue a new token pair",
|
||||
})
|
||||
@ApiOkResponse({ type: JwtAuthResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
refresh(@Body() dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
|
||||
return this.auth.refresh(dto);
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: "Revoke a refresh token; the operation is idempotent",
|
||||
})
|
||||
@ApiNoContentResponse()
|
||||
@ApiStandardErrors()
|
||||
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
|
||||
await this.auth.logout(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Users")
|
||||
@ApiBearerAuth("jwt")
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(SimpleJwtGuard)
|
||||
@Controller("users")
|
||||
export class SimpleUsersController {
|
||||
constructor(private readonly store: SimpleStore) {}
|
||||
|
||||
@Get("me")
|
||||
@ApiOperation({ summary: "Get the authenticated user" })
|
||||
@ApiOkResponse({ type: SimpleUserResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
me(@Req() request: DemoRequest): SimpleUserResponseDto {
|
||||
const user = this.store.findUserById(request.user!.id)!;
|
||||
return { data: this.store.publicUser(user) };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Products")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("products")
|
||||
export class SimpleProductsController {
|
||||
constructor(private readonly store: SimpleStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List products using offset pagination, filters and sorting",
|
||||
})
|
||||
@ApiOkResponse({ type: ProductsResponseDto })
|
||||
@ApiStandardErrors()
|
||||
list(@Query() query: ProductQueryDto): ProductsResponseDto {
|
||||
return this.store.listProducts(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one product with ETag support" })
|
||||
@ApiOkResponse({ type: ProductResponseDto })
|
||||
@ApiNotModifiedResponse({
|
||||
description: "The supplied If-None-Match value is current.",
|
||||
})
|
||||
@ApiStandardErrors({ notFound: true })
|
||||
get(
|
||||
@Param("id") id: string,
|
||||
@Headers("if-none-match") ifNoneMatch: string | undefined,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): ProductResponseDto | undefined {
|
||||
const product = this.store.getProduct(id);
|
||||
const etag = `W/\"${product.id}-v${product.version}\"`;
|
||||
response.setHeader("ETag", etag);
|
||||
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
||||
if (ifNoneMatch === etag) {
|
||||
response.status(304);
|
||||
return undefined;
|
||||
}
|
||||
return { data: product };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
|
||||
@ApiBearerAuth("jwt")
|
||||
@ApiOperation({ summary: "Create a product as an administrator" })
|
||||
@ApiCreatedResponse({ type: ProductResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
create(@Body() dto: CreateProductDto): ProductResponseDto {
|
||||
return { data: this.store.createProduct(dto) };
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
|
||||
@ApiBearerAuth("jwt")
|
||||
@ApiOperation({ summary: "Update a product using optimistic locking" })
|
||||
@ApiOkResponse({ type: ProductResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
): ProductResponseDto {
|
||||
return { data: this.store.updateProduct(id, dto) };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
|
||||
@ApiBearerAuth("jwt")
|
||||
@ApiOperation({ summary: "Delete a product as an administrator" })
|
||||
@ApiOkResponse({ type: MutationResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
remove(@Param("id") id: string): MutationResponseDto {
|
||||
this.store.deleteProduct(id);
|
||||
return { data: { id, success: true } };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Categories")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("categories")
|
||||
export class SimpleCategoriesController {
|
||||
constructor(private readonly store: SimpleStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List product categories" })
|
||||
@ApiOkResponse({ type: CategoriesResponseDto })
|
||||
list(): CategoriesResponseDto {
|
||||
return { data: this.store.listCategories() };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one category" })
|
||||
@ApiOkResponse({ type: CategoryResponseDto })
|
||||
@ApiStandardErrors({ notFound: true })
|
||||
get(@Param("id") id: string): CategoryResponseDto {
|
||||
return { data: this.store.getCategory(id) };
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Orders")
|
||||
@ApiBearerAuth("jwt")
|
||||
@ApiDemoScenarioHeader()
|
||||
@UseGuards(SimpleJwtGuard)
|
||||
@Controller("orders")
|
||||
export class SimpleOrdersController {
|
||||
constructor(private readonly store: SimpleStore) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List orders visible to the current user" })
|
||||
@ApiOkResponse({ type: OrdersResponseDto })
|
||||
@ApiStandardErrors({ auth: true })
|
||||
list(
|
||||
@Req() request: DemoRequest,
|
||||
@Query() query: PageQueryDto,
|
||||
): OrdersResponseDto {
|
||||
return this.store.listOrders(
|
||||
request.user!.id,
|
||||
request.user!.role,
|
||||
query.page,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get one visible order" })
|
||||
@ApiOkResponse({ type: OrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true })
|
||||
get(@Param("id") id: string, @Req() request: DemoRequest): OrderResponseDto {
|
||||
return {
|
||||
data: this.store.getOrder(id, request.user!.id, request.user!.role),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create an order and validate product stock" })
|
||||
@ApiCreatedResponse({ type: OrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, conflict: true })
|
||||
create(
|
||||
@Req() request: DemoRequest,
|
||||
@Body() dto: CreateOrderDto,
|
||||
): OrderResponseDto {
|
||||
return { data: this.store.createOrder(request.user!.id, dto) };
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: "Cancel an order if its state permits the transition",
|
||||
})
|
||||
@ApiOkResponse({ type: OrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
|
||||
cancel(
|
||||
@Param("id") id: string,
|
||||
@Req() request: DemoRequest,
|
||||
): OrderResponseDto {
|
||||
return {
|
||||
data: this.store.cancelOrder(id, request.user!.id, request.user!.role),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags("Testing")
|
||||
@ApiDemoScenarioHeader()
|
||||
@Controller("testing")
|
||||
export class SimpleTestingController {
|
||||
constructor(
|
||||
private readonly store: SimpleStore,
|
||||
private readonly auth: SimpleAuthService,
|
||||
) {}
|
||||
|
||||
@Get("scenarios")
|
||||
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
|
||||
@ApiOkResponse({ type: ScenariosResponseDto })
|
||||
scenarios(): ScenariosResponseDto {
|
||||
return {
|
||||
data: [
|
||||
{ name: "normal", description: "Default behavior." },
|
||||
{
|
||||
name: "slow",
|
||||
description:
|
||||
"Delays the response to exercise loading and cancellation states.",
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
description:
|
||||
"Delays long enough for a frontend timeout or cancellation.",
|
||||
},
|
||||
{
|
||||
name: "server-error",
|
||||
description: "Returns a deterministic 500 error.",
|
||||
},
|
||||
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
|
||||
{
|
||||
name: "empty",
|
||||
description: "Turns list responses into a valid empty state.",
|
||||
},
|
||||
{
|
||||
name: "expired-auth",
|
||||
description: "Makes protected routes return 401.",
|
||||
},
|
||||
{
|
||||
name: "forbidden",
|
||||
description: "Makes protected routes return 403.",
|
||||
},
|
||||
{ name: "conflict", description: "Makes mutation routes return 409." },
|
||||
{
|
||||
name: "large-dataset",
|
||||
description: "Expands list responses to 250 deterministic items.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Post("reset")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Reset all Simple API state and token revocations" })
|
||||
@ApiOkResponse({ type: TestingActionResponseDto })
|
||||
reset(): TestingActionResponseDto {
|
||||
this.store.reset("small");
|
||||
this.auth.reset();
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message: "Simple API state reset to the default deterministic seed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("seed/:preset")
|
||||
@HttpCode(200)
|
||||
@ApiParam({ name: "preset", enum: ["small", "large"] })
|
||||
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
|
||||
@ApiOkResponse({ type: TestingActionResponseDto })
|
||||
seed(@Param("preset") preset: string): TestingActionResponseDto {
|
||||
if (preset !== "small" && preset !== "large") {
|
||||
throw new BadRequestException({
|
||||
code: "UNKNOWN_SEED_PRESET",
|
||||
message: "Preset must be small or large.",
|
||||
});
|
||||
}
|
||||
this.store.reset(preset);
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message: `Simple API loaded the ${preset} dataset.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("users/:userId/role")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: "Change a user role to exercise dynamic access control",
|
||||
})
|
||||
@ApiOkResponse({ type: SimpleUserResponseDto })
|
||||
@ApiStandardErrors({ notFound: true })
|
||||
changeRole(
|
||||
@Param("userId") userId: string,
|
||||
@Body() dto: ChangeSimpleRoleDto,
|
||||
): SimpleUserResponseDto {
|
||||
return { data: this.store.changeRole(userId, dto.role) };
|
||||
}
|
||||
}
|
||||
365
examples/demo-backend/src/apps/simple/simple.dto.ts
Normal file
365
examples/demo-backend/src/apps/simple/simple.dto.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { PageMetaDto } from "../../common/api.dto";
|
||||
|
||||
export enum SimpleRole {
|
||||
Admin = "admin",
|
||||
Customer = "customer",
|
||||
}
|
||||
|
||||
export class SimpleUserDto {
|
||||
@ApiProperty({ example: "user-admin" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: "email", example: "admin@demo.local" })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: "Demo Admin" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: SimpleRole })
|
||||
role!: SimpleRole;
|
||||
|
||||
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=12" })
|
||||
avatarUrl!: string | null;
|
||||
}
|
||||
|
||||
export class SimpleUserResponseDto {
|
||||
@ApiProperty({ type: SimpleUserDto })
|
||||
data!: SimpleUserDto;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ format: "email", example: "admin@demo.local" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Refresh token returned by login or the previous refresh call.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class JwtTokensDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 60,
|
||||
description: "Access-token lifetime in seconds.",
|
||||
})
|
||||
expiresIn!: number;
|
||||
|
||||
@ApiProperty({ enum: ["Bearer"], example: "Bearer" })
|
||||
tokenType!: "Bearer";
|
||||
}
|
||||
|
||||
export class JwtAuthDataDto {
|
||||
@ApiProperty({ type: JwtTokensDto })
|
||||
tokens!: JwtTokensDto;
|
||||
|
||||
@ApiProperty({ type: SimpleUserDto })
|
||||
user!: SimpleUserDto;
|
||||
}
|
||||
|
||||
export class JwtAuthResponseDto {
|
||||
@ApiProperty({ type: JwtAuthDataDto })
|
||||
data!: JwtAuthDataDto;
|
||||
}
|
||||
|
||||
export enum ProductSort {
|
||||
Newest = "newest",
|
||||
PriceAsc = "price-asc",
|
||||
PriceDesc = "price-desc",
|
||||
Name = "name",
|
||||
}
|
||||
|
||||
export class ProductQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
|
||||
@ApiPropertyOptional({ example: "keyboard" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "category-electronics" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProductSort, default: ProductSort.Newest })
|
||||
@IsEnum(ProductSort)
|
||||
@IsOptional()
|
||||
sort = ProductSort.Newest;
|
||||
}
|
||||
|
||||
export class PageQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit = 20;
|
||||
}
|
||||
|
||||
export class SimpleProductDto {
|
||||
@ApiProperty({ example: "product-keyboard" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "Mechanical Keyboard" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "mechanical-keyboard" })
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: "Hot-swappable compact keyboard." })
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 12990,
|
||||
description: "Price in the smallest currency unit.",
|
||||
})
|
||||
priceCents!: number;
|
||||
|
||||
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
|
||||
currency!: "USD" | "EUR";
|
||||
|
||||
@ApiProperty({ example: "category-electronics" })
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ example: 24, minimum: 0 })
|
||||
stock!: number;
|
||||
|
||||
@ApiProperty({ example: 4.8, minimum: 0, maximum: 5 })
|
||||
rating!: number;
|
||||
|
||||
@ApiProperty({
|
||||
format: "uri",
|
||||
example: "https://picsum.photos/seed/keyboard/640/480",
|
||||
})
|
||||
imageUrl!: string;
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ example: 1, description: "Optimistic-lock version." })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class ProductsResponseDto {
|
||||
@ApiProperty({ type: [SimpleProductDto] })
|
||||
data!: SimpleProductDto[];
|
||||
|
||||
@ApiProperty({ type: PageMetaDto })
|
||||
meta!: PageMetaDto;
|
||||
}
|
||||
|
||||
export class ProductResponseDto {
|
||||
@ApiProperty({ type: SimpleProductDto })
|
||||
data!: SimpleProductDto;
|
||||
}
|
||||
|
||||
export class CreateProductDto {
|
||||
@ApiProperty({ example: "USB-C Dock" })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "Dock with HDMI, Ethernet and power delivery." })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(1000)
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ example: 8990, minimum: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priceCents!: number;
|
||||
|
||||
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
|
||||
@IsEnum(["USD", "EUR"])
|
||||
currency!: "USD" | "EUR";
|
||||
|
||||
@ApiProperty({ example: "category-electronics" })
|
||||
@IsString()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ example: 15, minimum: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stock!: number;
|
||||
|
||||
@ApiProperty({
|
||||
format: "uri",
|
||||
example: "https://picsum.photos/seed/dock/640/480",
|
||||
})
|
||||
@IsString()
|
||||
imageUrl!: string;
|
||||
}
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {
|
||||
@ApiProperty({
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
description: "Version last read by the frontend.",
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class SimpleCategoryDto {
|
||||
@ApiProperty({ example: "category-electronics" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "Electronics" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "electronics" })
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: 4 })
|
||||
productCount!: number;
|
||||
}
|
||||
|
||||
export class CategoriesResponseDto {
|
||||
@ApiProperty({ type: [SimpleCategoryDto] })
|
||||
data!: SimpleCategoryDto[];
|
||||
}
|
||||
|
||||
export class CategoryResponseDto {
|
||||
@ApiProperty({ type: SimpleCategoryDto })
|
||||
data!: SimpleCategoryDto;
|
||||
}
|
||||
|
||||
export enum OrderStatus {
|
||||
Pending = "pending",
|
||||
Paid = "paid",
|
||||
Shipped = "shipped",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export class CreateOrderItemDto {
|
||||
@ApiProperty({ example: "product-keyboard" })
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: 1, minimum: 1, maximum: 20 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
@ApiProperty({ type: [CreateOrderItemDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateOrderItemDto)
|
||||
items!: CreateOrderItemDto[];
|
||||
}
|
||||
|
||||
export class SimpleOrderItemDto {
|
||||
@ApiProperty({ example: "product-keyboard" })
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: "Mechanical Keyboard" })
|
||||
productName!: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ example: 12990 })
|
||||
unitPriceCents!: number;
|
||||
}
|
||||
|
||||
export class SimpleOrderDto {
|
||||
@ApiProperty({ example: "order-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "user-customer" })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ enum: OrderStatus })
|
||||
status!: OrderStatus;
|
||||
|
||||
@ApiProperty({ type: [SimpleOrderItemDto] })
|
||||
items!: SimpleOrderItemDto[];
|
||||
|
||||
@ApiProperty({ example: 17980 })
|
||||
totalCents!: number;
|
||||
|
||||
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
|
||||
currency!: "USD" | "EUR";
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class OrdersResponseDto {
|
||||
@ApiProperty({ type: [SimpleOrderDto] })
|
||||
data!: SimpleOrderDto[];
|
||||
|
||||
@ApiProperty({ type: PageMetaDto })
|
||||
meta!: PageMetaDto;
|
||||
}
|
||||
|
||||
export class OrderResponseDto {
|
||||
@ApiProperty({ type: SimpleOrderDto })
|
||||
data!: SimpleOrderDto;
|
||||
}
|
||||
|
||||
export class ChangeSimpleRoleDto {
|
||||
@ApiProperty({ enum: SimpleRole, example: SimpleRole.Customer })
|
||||
@IsEnum(SimpleRole)
|
||||
role!: SimpleRole;
|
||||
}
|
||||
33
examples/demo-backend/src/apps/simple/simple.module.ts
Normal file
33
examples/demo-backend/src/apps/simple/simple.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { InfrastructureModule } from "../../common/infrastructure.module";
|
||||
import {
|
||||
SimpleAdminGuard,
|
||||
SimpleAuthService,
|
||||
SimpleJwtGuard,
|
||||
} from "./simple.auth";
|
||||
import {
|
||||
SimpleAuthController,
|
||||
SimpleCategoriesController,
|
||||
SimpleHealthController,
|
||||
SimpleOrdersController,
|
||||
SimpleProductsController,
|
||||
SimpleTestingController,
|
||||
SimpleUsersController,
|
||||
} from "./simple.controllers";
|
||||
import { SimpleStore } from "./simple.store";
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule, JwtModule.register({})],
|
||||
controllers: [
|
||||
SimpleHealthController,
|
||||
SimpleAuthController,
|
||||
SimpleUsersController,
|
||||
SimpleProductsController,
|
||||
SimpleCategoriesController,
|
||||
SimpleOrdersController,
|
||||
SimpleTestingController,
|
||||
],
|
||||
providers: [SimpleStore, SimpleAuthService, SimpleJwtGuard, SimpleAdminGuard],
|
||||
})
|
||||
export class SimpleAppModule {}
|
||||
432
examples/demo-backend/src/apps/simple/simple.store.ts
Normal file
432
examples/demo-backend/src/apps/simple/simple.store.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
type CreateOrderDto,
|
||||
type CreateProductDto,
|
||||
OrderStatus,
|
||||
ProductSort,
|
||||
type ProductQueryDto,
|
||||
type SimpleCategoryDto,
|
||||
type SimpleOrderDto,
|
||||
type SimpleProductDto,
|
||||
SimpleRole,
|
||||
type SimpleUserDto,
|
||||
type UpdateProductDto,
|
||||
} from "./simple.dto";
|
||||
|
||||
interface SimpleUserRecord extends SimpleUserDto {
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimpleStore {
|
||||
private users: SimpleUserRecord[] = [];
|
||||
private categories: SimpleCategoryDto[] = [];
|
||||
private products: SimpleProductDto[] = [];
|
||||
private orders: SimpleOrderDto[] = [];
|
||||
private productSequence = 10;
|
||||
private orderSequence = 10;
|
||||
|
||||
constructor() {
|
||||
this.reset("small");
|
||||
}
|
||||
|
||||
reset(preset: "small" | "large" = "small"): void {
|
||||
this.users = [
|
||||
{
|
||||
id: "user-admin",
|
||||
email: "admin@demo.local",
|
||||
password: "demo1234",
|
||||
name: "Demo Admin",
|
||||
role: SimpleRole.Admin,
|
||||
avatarUrl: "https://i.pravatar.cc/160?img=12",
|
||||
},
|
||||
{
|
||||
id: "user-customer",
|
||||
email: "customer@demo.local",
|
||||
password: "demo1234",
|
||||
name: "Demo Customer",
|
||||
role: SimpleRole.Customer,
|
||||
avatarUrl: null,
|
||||
},
|
||||
];
|
||||
this.categories = [
|
||||
{
|
||||
id: "category-electronics",
|
||||
name: "Electronics",
|
||||
slug: "electronics",
|
||||
productCount: 0,
|
||||
},
|
||||
{
|
||||
id: "category-home",
|
||||
name: "Home office",
|
||||
slug: "home-office",
|
||||
productCount: 0,
|
||||
},
|
||||
{ id: "category-books", name: "Books", slug: "books", productCount: 0 },
|
||||
];
|
||||
const baseProducts: SimpleProductDto[] = [
|
||||
this.product(
|
||||
"product-keyboard",
|
||||
"Mechanical Keyboard",
|
||||
12990,
|
||||
"category-electronics",
|
||||
24,
|
||||
4.8,
|
||||
),
|
||||
this.product(
|
||||
"product-mouse",
|
||||
"Ergonomic Mouse",
|
||||
6990,
|
||||
"category-electronics",
|
||||
42,
|
||||
4.6,
|
||||
),
|
||||
this.product(
|
||||
"product-desk",
|
||||
"Standing Desk",
|
||||
45900,
|
||||
"category-home",
|
||||
8,
|
||||
4.9,
|
||||
),
|
||||
this.product(
|
||||
"product-lamp",
|
||||
"Focus Desk Lamp",
|
||||
5490,
|
||||
"category-home",
|
||||
31,
|
||||
4.5,
|
||||
),
|
||||
this.product(
|
||||
"product-book",
|
||||
"Frontend Systems Handbook",
|
||||
3990,
|
||||
"category-books",
|
||||
100,
|
||||
4.7,
|
||||
),
|
||||
this.product(
|
||||
"product-headphones",
|
||||
"Studio Headphones",
|
||||
18990,
|
||||
"category-electronics",
|
||||
12,
|
||||
4.4,
|
||||
),
|
||||
];
|
||||
this.products =
|
||||
preset === "large"
|
||||
? Array.from({ length: 250 }, (_, index) => {
|
||||
const source = baseProducts[index % baseProducts.length];
|
||||
const number = index + 1;
|
||||
return {
|
||||
...source,
|
||||
id: `product-${String(number).padStart(3, "0")}`,
|
||||
name: `${source.name} ${number}`,
|
||||
slug: `${source.slug}-${number}`,
|
||||
};
|
||||
})
|
||||
: baseProducts;
|
||||
this.orders = [
|
||||
{
|
||||
id: "order-001",
|
||||
userId: "user-customer",
|
||||
status: OrderStatus.Paid,
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
productName: "Mechanical Keyboard",
|
||||
quantity: 1,
|
||||
unitPriceCents: 12990,
|
||||
},
|
||||
],
|
||||
totalCents: 12990,
|
||||
currency: "USD",
|
||||
createdAt: "2026-07-20T10:30:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "order-002",
|
||||
userId: "user-customer",
|
||||
status: OrderStatus.Shipped,
|
||||
items: [
|
||||
{
|
||||
productId: "product-book",
|
||||
productName: "Frontend Systems Handbook",
|
||||
quantity: 1,
|
||||
unitPriceCents: 3990,
|
||||
},
|
||||
],
|
||||
totalCents: 3990,
|
||||
currency: "USD",
|
||||
createdAt: "2026-07-22T14:00:00.000Z",
|
||||
},
|
||||
];
|
||||
this.productSequence = this.products.length + 10;
|
||||
this.orderSequence = 10;
|
||||
this.updateCategoryCounts();
|
||||
}
|
||||
|
||||
findUserByEmail(email: string): SimpleUserRecord | undefined {
|
||||
return this.users.find(
|
||||
(user) => user.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
findUserById(id: string): SimpleUserRecord | undefined {
|
||||
return this.users.find((user) => user.id === id);
|
||||
}
|
||||
|
||||
publicUser(user: SimpleUserRecord): SimpleUserDto {
|
||||
const { password: _password, ...publicUser } = user;
|
||||
return publicUser;
|
||||
}
|
||||
|
||||
changeRole(userId: string, role: SimpleRole): SimpleUserDto {
|
||||
const user = this.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException({
|
||||
code: "USER_NOT_FOUND",
|
||||
message: "User not found.",
|
||||
});
|
||||
}
|
||||
user.role = role;
|
||||
return this.publicUser(user);
|
||||
}
|
||||
|
||||
listProducts(query: ProductQueryDto): {
|
||||
data: SimpleProductDto[];
|
||||
meta: { page: number; limit: number; total: number; totalPages: number };
|
||||
} {
|
||||
let products = [...this.products];
|
||||
if (query.search) {
|
||||
const search = query.search.toLowerCase();
|
||||
products = products.filter((product) =>
|
||||
`${product.name} ${product.description}`.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
if (query.categoryId) {
|
||||
products = products.filter(
|
||||
(product) => product.categoryId === query.categoryId,
|
||||
);
|
||||
}
|
||||
products.sort((left, right) => {
|
||||
if (query.sort === ProductSort.PriceAsc)
|
||||
return left.priceCents - right.priceCents;
|
||||
if (query.sort === ProductSort.PriceDesc)
|
||||
return right.priceCents - left.priceCents;
|
||||
if (query.sort === ProductSort.Name)
|
||||
return left.name.localeCompare(right.name);
|
||||
return right.createdAt.localeCompare(left.createdAt);
|
||||
});
|
||||
const total = products.length;
|
||||
const start = (query.page - 1) * query.limit;
|
||||
return {
|
||||
data: products.slice(start, start + query.limit),
|
||||
meta: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / query.limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getProduct(id: string): SimpleProductDto {
|
||||
const product = this.products.find((item) => item.id === id);
|
||||
if (!product) {
|
||||
throw new NotFoundException({
|
||||
code: "PRODUCT_NOT_FOUND",
|
||||
message: "Product not found.",
|
||||
});
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
createProduct(dto: CreateProductDto): SimpleProductDto {
|
||||
if (!this.categories.some((category) => category.id === dto.categoryId)) {
|
||||
throw new UnprocessableEntityException({
|
||||
code: "CATEGORY_NOT_FOUND",
|
||||
message: "Selected category does not exist.",
|
||||
details: [{ field: "categoryId", message: "Unknown category." }],
|
||||
});
|
||||
}
|
||||
const id = `product-${this.productSequence++}`;
|
||||
const product: SimpleProductDto = {
|
||||
id,
|
||||
name: dto.name,
|
||||
slug: `${dto.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${id}`,
|
||||
description: dto.description,
|
||||
priceCents: dto.priceCents,
|
||||
currency: dto.currency,
|
||||
categoryId: dto.categoryId,
|
||||
stock: dto.stock,
|
||||
rating: 0,
|
||||
imageUrl: dto.imageUrl,
|
||||
createdAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
};
|
||||
this.products.unshift(product);
|
||||
this.updateCategoryCounts();
|
||||
return product;
|
||||
}
|
||||
|
||||
updateProduct(id: string, dto: UpdateProductDto): SimpleProductDto {
|
||||
const product = this.getProduct(id);
|
||||
if (product.version !== dto.version) {
|
||||
throw new ConflictException({
|
||||
code: "PRODUCT_VERSION_CONFLICT",
|
||||
message: "Product was changed by another user.",
|
||||
details: [
|
||||
{
|
||||
field: "version",
|
||||
message: `Expected ${product.version}, received ${dto.version}.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const { version: _version, ...changes } = dto;
|
||||
Object.assign(product, changes, { version: product.version + 1 });
|
||||
this.updateCategoryCounts();
|
||||
return product;
|
||||
}
|
||||
|
||||
deleteProduct(id: string): void {
|
||||
this.getProduct(id);
|
||||
this.products = this.products.filter((product) => product.id !== id);
|
||||
this.updateCategoryCounts();
|
||||
}
|
||||
|
||||
listCategories(): SimpleCategoryDto[] {
|
||||
return this.categories;
|
||||
}
|
||||
|
||||
getCategory(id: string): SimpleCategoryDto {
|
||||
const category = this.categories.find((item) => item.id === id);
|
||||
if (!category) {
|
||||
throw new NotFoundException({
|
||||
code: "CATEGORY_NOT_FOUND",
|
||||
message: "Category not found.",
|
||||
});
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
listOrders(userId: string, role: string, page = 1, limit = 20) {
|
||||
const orders =
|
||||
role === SimpleRole.Admin
|
||||
? this.orders
|
||||
: this.orders.filter((order) => order.userId === userId);
|
||||
const total = orders.length;
|
||||
const start = (page - 1) * limit;
|
||||
return {
|
||||
data: orders.slice(start, start + limit),
|
||||
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
getOrder(id: string, userId: string, role: string): SimpleOrderDto {
|
||||
const order = this.orders.find(
|
||||
(item) =>
|
||||
item.id === id && (role === SimpleRole.Admin || item.userId === userId),
|
||||
);
|
||||
if (!order) {
|
||||
throw new NotFoundException({
|
||||
code: "ORDER_NOT_FOUND",
|
||||
message: "Order not found.",
|
||||
});
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
createOrder(userId: string, dto: CreateOrderDto): SimpleOrderDto {
|
||||
if (dto.items.length === 0) {
|
||||
throw new UnprocessableEntityException({
|
||||
code: "EMPTY_ORDER",
|
||||
message: "Order must contain at least one item.",
|
||||
details: [{ field: "items", message: "Add at least one item." }],
|
||||
});
|
||||
}
|
||||
const items = dto.items.map(({ productId, quantity }) => {
|
||||
const product = this.getProduct(productId);
|
||||
if (product.stock < quantity) {
|
||||
throw new ConflictException({
|
||||
code: "INSUFFICIENT_STOCK",
|
||||
message: `Not enough stock for ${product.name}.`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
productId,
|
||||
productName: product.name,
|
||||
quantity,
|
||||
unitPriceCents: product.priceCents,
|
||||
};
|
||||
});
|
||||
const order: SimpleOrderDto = {
|
||||
id: `order-${String(this.orderSequence++).padStart(3, "0")}`,
|
||||
userId,
|
||||
status: OrderStatus.Pending,
|
||||
items,
|
||||
totalCents: items.reduce(
|
||||
(sum, item) => sum + item.unitPriceCents * item.quantity,
|
||||
0,
|
||||
),
|
||||
currency: "USD",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.orders.unshift(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
cancelOrder(id: string, userId: string, role: string): SimpleOrderDto {
|
||||
const order = this.getOrder(id, userId, role);
|
||||
if (
|
||||
order.status === OrderStatus.Shipped ||
|
||||
order.status === OrderStatus.Cancelled
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: "ORDER_CANNOT_BE_CANCELLED",
|
||||
message: `Order in ${order.status} status cannot be cancelled.`,
|
||||
});
|
||||
}
|
||||
order.status = OrderStatus.Cancelled;
|
||||
return order;
|
||||
}
|
||||
|
||||
private product(
|
||||
id: string,
|
||||
name: string,
|
||||
priceCents: number,
|
||||
categoryId: string,
|
||||
stock: number,
|
||||
rating: number,
|
||||
): SimpleProductDto {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description: `${name} is a deterministic demo product used by frontend examples.`,
|
||||
priceCents,
|
||||
currency: "USD",
|
||||
categoryId,
|
||||
stock,
|
||||
rating,
|
||||
imageUrl: `https://picsum.photos/seed/${slug}/640/480`,
|
||||
createdAt: `2026-07-${String(10 + this.products.length).padStart(2, "0")}T09:00:00.000Z`,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private updateCategoryCounts(): void {
|
||||
for (const category of this.categories) {
|
||||
category.productCount = this.products.filter(
|
||||
(product) => product.categoryId === category.id,
|
||||
).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
examples/demo-backend/src/common/api-exception.filter.ts
Normal file
79
examples/demo-backend/src/common/api-exception.filter.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import type { DemoRequest } from "./request.types";
|
||||
|
||||
interface HttpErrorBody {
|
||||
code?: string;
|
||||
message?: string | string[];
|
||||
details?: unknown[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const STATUS_CODES: Record<number, string> = {
|
||||
400: "BAD_REQUEST",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
409: "CONFLICT",
|
||||
413: "PAYLOAD_TOO_LARGE",
|
||||
422: "UNPROCESSABLE_ENTITY",
|
||||
429: "RATE_LIMITED",
|
||||
500: "INTERNAL_SERVER_ERROR",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const request = context.getRequest<Request>() as DemoRequest;
|
||||
const response = context.getResponse<Response>();
|
||||
const externalCode =
|
||||
typeof exception === "object" && exception !== null && "code" in exception
|
||||
? String((exception as { code: unknown }).code)
|
||||
: undefined;
|
||||
const isOversizedFile = externalCode === "LIMIT_FILE_SIZE";
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: isOversizedFile
|
||||
? HttpStatus.PAYLOAD_TOO_LARGE
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const rawBody =
|
||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||
const body: HttpErrorBody =
|
||||
typeof rawBody === "object" && rawBody !== null
|
||||
? (rawBody as HttpErrorBody)
|
||||
: {};
|
||||
const validationMessages = Array.isArray(body.message) ? body.message : [];
|
||||
const publicMessage = isOversizedFile
|
||||
? "Uploaded file exceeds the 5 MiB limit"
|
||||
: status === 500 && !(exception instanceof HttpException)
|
||||
? "Internal server error"
|
||||
: Array.isArray(body.message)
|
||||
? "Request validation failed"
|
||||
: (body.message ??
|
||||
(typeof rawBody === "string" ? rawBody : body.error) ??
|
||||
"Request failed");
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
code: isOversizedFile || status === HttpStatus.PAYLOAD_TOO_LARGE
|
||||
? "FILE_TOO_LARGE"
|
||||
: (body.code ?? STATUS_CODES[status] ?? `HTTP_${status}`),
|
||||
message: publicMessage,
|
||||
details:
|
||||
body.details ?? validationMessages.map((message) => ({ message })),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.originalUrl,
|
||||
requestId: request.requestId ?? "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
122
examples/demo-backend/src/common/api.decorators.ts
Normal file
122
examples/demo-backend/src/common/api.decorators.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { applyDecorators } from "@nestjs/common";
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiConflictResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiHeader,
|
||||
ApiInternalServerErrorResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiResponse,
|
||||
ApiTooManyRequestsResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { ErrorResponseDto } from "./api.dto";
|
||||
|
||||
export function ApiStandardErrors(
|
||||
options: { auth?: boolean; notFound?: boolean; conflict?: boolean } = {},
|
||||
) {
|
||||
const decorators: Array<
|
||||
ClassDecorator | MethodDecorator | PropertyDecorator
|
||||
> = [
|
||||
ApiBadRequestResponse({
|
||||
description: "Invalid request or validation error.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
ApiTooManyRequestsResponse({
|
||||
description: "Demo rate limit scenario.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
ApiInternalServerErrorResponse({
|
||||
description: "Unexpected or simulated server error.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
];
|
||||
|
||||
if (options.auth) {
|
||||
decorators.push(
|
||||
ApiUnauthorizedResponse({
|
||||
description: "Authentication is missing or expired.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
ApiForbiddenResponse({
|
||||
description: "The current user lacks permission.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.notFound) {
|
||||
decorators.push(
|
||||
ApiNotFoundResponse({
|
||||
description: "The requested resource does not exist.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.conflict) {
|
||||
decorators.push(
|
||||
ApiConflictResponse({
|
||||
description: "Business or optimistic-lock conflict.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function ApiDemoScenarioHeader() {
|
||||
return ApiHeader({
|
||||
name: "X-Demo-Scenario",
|
||||
required: false,
|
||||
enum: [
|
||||
"normal",
|
||||
"slow",
|
||||
"timeout",
|
||||
"server-error",
|
||||
"rate-limited",
|
||||
"empty",
|
||||
"expired-auth",
|
||||
"forbidden",
|
||||
"conflict",
|
||||
"large-dataset",
|
||||
],
|
||||
description:
|
||||
"Forces a deterministic frontend-testing scenario for this request.",
|
||||
});
|
||||
}
|
||||
|
||||
export function ApiOrganizationHeader() {
|
||||
return ApiHeader({
|
||||
name: "X-Organization-Id",
|
||||
required: true,
|
||||
example: "org-acme",
|
||||
description: "Current tenant. The authenticated user must be a member.",
|
||||
});
|
||||
}
|
||||
|
||||
export function ApiIdempotencyHeader() {
|
||||
return ApiHeader({
|
||||
name: "Idempotency-Key",
|
||||
required: true,
|
||||
example: "checkout-6c5f92ad",
|
||||
description:
|
||||
"Repeating a request with the same key returns the original order.",
|
||||
});
|
||||
}
|
||||
|
||||
export function ApiBinaryResponse(
|
||||
description: string,
|
||||
mediaType = "application/octet-stream",
|
||||
) {
|
||||
return ApiResponse({
|
||||
status: 200,
|
||||
description,
|
||||
content: {
|
||||
[mediaType]: {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
135
examples/demo-backend/src/common/api.dto.ts
Normal file
135
examples/demo-backend/src/common/api.dto.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
export class ErrorDetailDto {
|
||||
@ApiPropertyOptional({ example: "email" })
|
||||
field?: string;
|
||||
|
||||
@ApiProperty({ example: "must be an email" })
|
||||
message!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "isEmail" })
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export class ErrorResponseDto {
|
||||
@ApiProperty({ example: 404 })
|
||||
statusCode!: number;
|
||||
|
||||
@ApiProperty({ example: "PRODUCT_NOT_FOUND" })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: "Product not found" })
|
||||
message!: string;
|
||||
|
||||
@ApiProperty({ type: [ErrorDetailDto] })
|
||||
details!: ErrorDetailDto[];
|
||||
|
||||
@ApiProperty({ format: "date-time", example: "2026-07-30T12:00:00.000Z" })
|
||||
timestamp!: string;
|
||||
|
||||
@ApiProperty({ example: "/api/v1/products/product-404" })
|
||||
path!: string;
|
||||
|
||||
@ApiProperty({ example: "req-5c9f7a3d" })
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
export class PageMetaDto {
|
||||
@ApiProperty({ example: 1, minimum: 1 })
|
||||
page!: number;
|
||||
|
||||
@ApiProperty({ example: 20, minimum: 1 })
|
||||
limit!: number;
|
||||
|
||||
@ApiProperty({ example: 48, minimum: 0 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ example: 3, minimum: 0 })
|
||||
totalPages!: number;
|
||||
}
|
||||
|
||||
export class CursorMetaDto {
|
||||
@ApiProperty({ example: 20, minimum: 1 })
|
||||
limit!: number;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: "product-020" })
|
||||
nextCursor!: string | null;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
hasMore!: boolean;
|
||||
}
|
||||
|
||||
export class MutationResultDto {
|
||||
@ApiProperty({ example: "product-001" })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
success!: boolean;
|
||||
}
|
||||
|
||||
export class MutationResponseDto {
|
||||
@ApiProperty({ type: MutationResultDto })
|
||||
data!: MutationResultDto;
|
||||
}
|
||||
|
||||
export class HealthDataDto {
|
||||
@ApiProperty({ enum: ["simple", "complex"], example: "simple" })
|
||||
application!: "simple" | "complex";
|
||||
|
||||
@ApiProperty({ enum: ["ok"], example: "ok" })
|
||||
status!: "ok";
|
||||
|
||||
@ApiProperty({ format: "date-time" })
|
||||
timestamp!: string;
|
||||
|
||||
@ApiProperty({ example: "1.0.0" })
|
||||
version!: string;
|
||||
}
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ type: HealthDataDto })
|
||||
data!: HealthDataDto;
|
||||
}
|
||||
|
||||
export const DEMO_SCENARIOS = [
|
||||
"normal",
|
||||
"slow",
|
||||
"timeout",
|
||||
"server-error",
|
||||
"rate-limited",
|
||||
"empty",
|
||||
"expired-auth",
|
||||
"forbidden",
|
||||
"conflict",
|
||||
"large-dataset",
|
||||
] as const;
|
||||
|
||||
export type DemoScenario = (typeof DEMO_SCENARIOS)[number];
|
||||
|
||||
export class ScenarioDto {
|
||||
@ApiProperty({ example: "slow" })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "Delays the response to exercise loading and cancellation states.",
|
||||
})
|
||||
description!: string;
|
||||
}
|
||||
|
||||
export class ScenariosResponseDto {
|
||||
@ApiProperty({ type: [ScenarioDto] })
|
||||
data!: ScenarioDto[];
|
||||
}
|
||||
|
||||
export class TestingActionDataDto {
|
||||
@ApiProperty({ example: true })
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty({ example: "State reset to the default deterministic seed." })
|
||||
message!: string;
|
||||
}
|
||||
|
||||
export class TestingActionResponseDto {
|
||||
@ApiProperty({ type: TestingActionDataDto })
|
||||
data!: TestingActionDataDto;
|
||||
}
|
||||
57
examples/demo-backend/src/common/configure-application.ts
Normal file
57
examples/demo-backend/src/common/configure-application.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ApiExceptionFilter } from "./api-exception.filter";
|
||||
import { ObservabilityInterceptor } from "./observability.interceptor";
|
||||
import type { DemoRequest } from "./request.types";
|
||||
import { ScenarioInterceptor } from "./scenario.interceptor";
|
||||
|
||||
export function configureApplication(app: NestExpressApplication): void {
|
||||
app.setGlobalPrefix("api/v1");
|
||||
app.use(cookieParser());
|
||||
app.use((request: Request, response: Response, next: NextFunction) => {
|
||||
const demoRequest = request as DemoRequest;
|
||||
demoRequest.requestId = String(
|
||||
request.headers["x-request-id"] ?? `req-${randomUUID()}`,
|
||||
);
|
||||
response.setHeader("X-Request-Id", demoRequest.requestId);
|
||||
next();
|
||||
});
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"X-CSRF-Token",
|
||||
"X-Demo-Scenario",
|
||||
"X-Organization-Id",
|
||||
"X-Request-Id",
|
||||
"Idempotency-Key",
|
||||
"If-None-Match",
|
||||
],
|
||||
exposedHeaders: [
|
||||
"ETag",
|
||||
"Retry-After",
|
||||
"X-Request-Id",
|
||||
"X-Response-Time",
|
||||
"X-Demo-Scenario",
|
||||
],
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(app.get(ApiExceptionFilter));
|
||||
app.useGlobalInterceptors(
|
||||
app.get(ObservabilityInterceptor),
|
||||
app.get(ScenarioInterceptor),
|
||||
);
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
15
examples/demo-backend/src/common/infrastructure.module.ts
Normal file
15
examples/demo-backend/src/common/infrastructure.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ApiExceptionFilter } from "./api-exception.filter";
|
||||
import { ObservabilityInterceptor } from "./observability.interceptor";
|
||||
import { ScenarioInterceptor } from "./scenario.interceptor";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
ApiExceptionFilter,
|
||||
ObservabilityInterceptor,
|
||||
ScenarioInterceptor,
|
||||
],
|
||||
exports: [ApiExceptionFilter, ObservabilityInterceptor, ScenarioInterceptor],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { Observable, tap } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class ObservabilityInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const startedAt = performance.now();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const setTiming = () => {
|
||||
if (!response.headersSent) {
|
||||
response.setHeader(
|
||||
"X-Response-Time",
|
||||
`${Math.round(performance.now() - startedAt)}ms`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return next.handle().pipe(tap({ next: setTiming, error: setTiming }));
|
||||
}
|
||||
}
|
||||
71
examples/demo-backend/src/common/openapi.ts
Normal file
71
examples/demo-backend/src/common/openapi.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { INestApplication } from "@nestjs/common";
|
||||
import {
|
||||
DocumentBuilder,
|
||||
SwaggerModule,
|
||||
type OpenAPIObject,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
interface OpenApiOptions {
|
||||
kind: "simple" | "complex";
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function createOpenApiDocument(
|
||||
app: INestApplication,
|
||||
options: OpenApiOptions,
|
||||
): OpenAPIObject {
|
||||
const isSimple = options.kind === "simple";
|
||||
const builder = new DocumentBuilder()
|
||||
.setTitle(isSimple ? "Demo Simple API" : "Demo Complex API")
|
||||
.setDescription(
|
||||
isSimple
|
||||
? "JWT-based API for landing pages and medium frontend applications."
|
||||
: "Cookie-session, multitenant and realtime API for large frontend applications.",
|
||||
)
|
||||
.setVersion("1.0.0")
|
||||
.addServer(`http://localhost:${options.port}`, "Local development");
|
||||
|
||||
if (isSimple) {
|
||||
builder.addBearerAuth(
|
||||
{ type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
"jwt",
|
||||
);
|
||||
} else {
|
||||
builder
|
||||
.addCookieAuth(
|
||||
"demo_session",
|
||||
{ type: "apiKey", in: "cookie" },
|
||||
"cookieSession",
|
||||
)
|
||||
.addApiKey(
|
||||
{
|
||||
type: "apiKey",
|
||||
in: "header",
|
||||
name: "X-CSRF-Token",
|
||||
description: "Required for authenticated mutations.",
|
||||
},
|
||||
"csrf",
|
||||
);
|
||||
}
|
||||
|
||||
return SwaggerModule.createDocument(app, builder.build(), {
|
||||
operationIdFactory: (controllerKey, methodKey) =>
|
||||
`${controllerKey.replace(/Controller$/, "")}_${methodKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function mountOpenApi(
|
||||
app: INestApplication,
|
||||
document: OpenAPIObject,
|
||||
title: string,
|
||||
): void {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
jsonDocumentUrl: "/openapi.json",
|
||||
customSiteTitle: title,
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
14
examples/demo-backend/src/common/request.types.ts
Normal file
14
examples/demo-backend/src/common/request.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request } from "express";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface DemoRequest extends Request {
|
||||
requestId: string;
|
||||
user?: AuthenticatedUser;
|
||||
organizationId?: string;
|
||||
}
|
||||
145
examples/demo-backend/src/common/scenario.interceptor.ts
Normal file
145
examples/demo-backend/src/common/scenario.interceptor.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ConflictException,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NestInterceptor,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { Observable } from "rxjs";
|
||||
import { delay, map } from "rxjs/operators";
|
||||
import { DEMO_SCENARIOS, type DemoScenario } from "./api.dto";
|
||||
|
||||
function scenarioFromRequest(request: Request): DemoScenario {
|
||||
const value = String(request.headers["x-demo-scenario"] ?? "normal");
|
||||
return DEMO_SCENARIOS.includes(value as DemoScenario)
|
||||
? (value as DemoScenario)
|
||||
: "normal";
|
||||
}
|
||||
|
||||
export function assertNoForcedAuthFailure(request: Request): void {
|
||||
const scenario = scenarioFromRequest(request);
|
||||
if (scenario === "expired-auth") {
|
||||
throw new UnauthorizedException({
|
||||
code: "DEMO_AUTH_EXPIRED",
|
||||
message: "Authentication was expired by the demo scenario.",
|
||||
});
|
||||
}
|
||||
if (scenario === "forbidden") {
|
||||
throw new ForbiddenException({
|
||||
code: "DEMO_FORBIDDEN",
|
||||
message: "Access was denied by the demo scenario.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScenarioInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<Request>();
|
||||
const response = http.getResponse<Response>();
|
||||
const scenario = scenarioFromRequest(request);
|
||||
response.setHeader("X-Demo-Scenario", scenario);
|
||||
|
||||
if (scenario === "server-error") {
|
||||
throw new InternalServerErrorException({
|
||||
code: "DEMO_SERVER_ERROR",
|
||||
message: "Server error forced by X-Demo-Scenario.",
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario === "rate-limited") {
|
||||
response.setHeader("Retry-After", "3");
|
||||
throw new HttpException(
|
||||
{
|
||||
code: "DEMO_RATE_LIMITED",
|
||||
message: "Rate limit forced by X-Demo-Scenario.",
|
||||
},
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
scenario === "conflict" &&
|
||||
!["GET", "HEAD", "OPTIONS"].includes(request.method)
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: "DEMO_CONFLICT",
|
||||
message: "Conflict forced by X-Demo-Scenario.",
|
||||
});
|
||||
}
|
||||
|
||||
const configuredDelay =
|
||||
scenario === "slow"
|
||||
? Number(process.env.MOCK_SLOW_DELAY_MS ?? 1500)
|
||||
: scenario === "timeout"
|
||||
? Number(process.env.MOCK_TIMEOUT_DELAY_MS ?? 30000)
|
||||
: 0;
|
||||
|
||||
return next.handle().pipe(
|
||||
configuredDelay > 0 ? delay(configuredDelay) : (source) => source,
|
||||
map((payload) => this.transformPayload(payload, scenario)),
|
||||
);
|
||||
}
|
||||
|
||||
private transformPayload(payload: unknown, scenario: DemoScenario): unknown {
|
||||
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const response = payload as {
|
||||
data: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
if (!Array.isArray(response.data)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (scenario === "empty") {
|
||||
return {
|
||||
...response,
|
||||
data: [],
|
||||
meta: response.meta
|
||||
? {
|
||||
...response.meta,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
}
|
||||
: response.meta,
|
||||
};
|
||||
}
|
||||
|
||||
if (scenario === "large-dataset" && response.data.length > 0) {
|
||||
const source = response.data as Array<Record<string, unknown>>;
|
||||
const data = Array.from({ length: 250 }, (_, index) => {
|
||||
const original = source[index % source.length];
|
||||
return {
|
||||
...original,
|
||||
id: `${String(original.id ?? "item")}-scenario-${String(index + 1).padStart(3, "0")}`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
data,
|
||||
meta: response.meta
|
||||
? {
|
||||
...response.meta,
|
||||
total: data.length,
|
||||
totalPages: 1,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
}
|
||||
: response.meta,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
34
examples/demo-backend/src/scripts/generate-openapi.ts
Normal file
34
examples/demo-backend/src/scripts/generate-openapi.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { createComplexApplication } from "../apps/complex/bootstrap";
|
||||
import { createSimpleApplication } from "../apps/simple/bootstrap";
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
const outputDirectory = resolve(process.cwd(), "openapi");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const simple = await createSimpleApplication(false);
|
||||
await simple.app.init();
|
||||
await writeFile(
|
||||
resolve(outputDirectory, "simple.json"),
|
||||
`${JSON.stringify(simple.document, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await simple.app.close();
|
||||
|
||||
const complex = await createComplexApplication(false);
|
||||
await complex.app.init();
|
||||
await writeFile(
|
||||
resolve(outputDirectory, "complex.json"),
|
||||
`${JSON.stringify(complex.document, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await complex.app.close();
|
||||
|
||||
console.log("Generated openapi/simple.json and openapi/complex.json");
|
||||
}
|
||||
|
||||
void generate().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
92
examples/demo-backend/src/scripts/validate-openapi.ts
Normal file
92
examples/demo-backend/src/scripts/validate-openapi.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import type { OpenAPIObject } from "@nestjs/swagger";
|
||||
|
||||
interface ContractExpectation {
|
||||
file: string;
|
||||
requiredSecuritySchemes: string[];
|
||||
requiredPath: string;
|
||||
forbiddenPath: string;
|
||||
}
|
||||
|
||||
async function validateContract(
|
||||
expectation: ContractExpectation,
|
||||
): Promise<void> {
|
||||
const filePath = resolve(process.cwd(), "openapi", expectation.file);
|
||||
await SwaggerParser.validate(filePath);
|
||||
const document = JSON.parse(
|
||||
await readFile(filePath, "utf8"),
|
||||
) as OpenAPIObject;
|
||||
const operationIds = new Set<string>();
|
||||
let operationCount = 0;
|
||||
|
||||
for (const pathItem of Object.values(document.paths)) {
|
||||
if (!pathItem) continue;
|
||||
for (const method of [
|
||||
"get",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
"options",
|
||||
"head",
|
||||
] as const) {
|
||||
const operation = pathItem[method] as
|
||||
{ operationId?: string } | undefined;
|
||||
if (!operation) continue;
|
||||
operationCount += 1;
|
||||
if (!operation.operationId)
|
||||
throw new Error(
|
||||
`${expectation.file}: ${method.toUpperCase()} operation has no operationId.`,
|
||||
);
|
||||
if (operationIds.has(operation.operationId))
|
||||
throw new Error(
|
||||
`${expectation.file}: duplicate operationId ${operation.operationId}.`,
|
||||
);
|
||||
operationIds.add(operation.operationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.paths[expectation.requiredPath])
|
||||
throw new Error(
|
||||
`${expectation.file}: required path ${expectation.requiredPath} is missing.`,
|
||||
);
|
||||
if (document.paths[expectation.forbiddenPath])
|
||||
throw new Error(
|
||||
`${expectation.file}: path ${expectation.forbiddenPath} leaked from the other application.`,
|
||||
);
|
||||
for (const scheme of expectation.requiredSecuritySchemes) {
|
||||
if (!document.components?.securitySchemes?.[scheme])
|
||||
throw new Error(
|
||||
`${expectation.file}: security scheme ${scheme} is missing.`,
|
||||
);
|
||||
}
|
||||
if (operationCount < 10)
|
||||
throw new Error(
|
||||
`${expectation.file}: suspiciously small contract (${operationCount} operations).`,
|
||||
);
|
||||
console.log(
|
||||
`Validated ${expectation.file}: ${operationCount} operations, ${operationIds.size} unique operation IDs.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function validate(): Promise<void> {
|
||||
await validateContract({
|
||||
file: "simple.json",
|
||||
requiredSecuritySchemes: ["jwt"],
|
||||
requiredPath: "/api/v1/products",
|
||||
forbiddenPath: "/api/v1/organizations",
|
||||
});
|
||||
await validateContract({
|
||||
file: "complex.json",
|
||||
requiredSecuritySchemes: ["cookieSession", "csrf"],
|
||||
requiredPath: "/api/v1/organizations",
|
||||
forbiddenPath: "/api/v1/auth/refresh-token",
|
||||
});
|
||||
}
|
||||
|
||||
void validate().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user