feat: add example

This commit is contained in:
2026-08-01 09:31:08 +03:00
parent 15805e28df
commit 26b59686a5
434 changed files with 34975 additions and 4995 deletions

View File

@@ -270,7 +270,7 @@ export class SimpleOrdersController {
@Post()
@ApiOperation({ summary: "Create an order and validate product stock" })
@ApiCreatedResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, conflict: true })
@ApiStandardErrors({ auth: true, conflict: true, unprocessable: true })
create(
@Req() request: DemoRequest,
@Body() dto: CreateOrderDto,

View File

@@ -9,6 +9,7 @@ import {
IsString,
Max,
MaxLength,
Matches,
Min,
MinLength,
ValidateNested,
@@ -243,6 +244,9 @@ export class CreateProductDto {
example: "https://picsum.photos/seed/dock/640/480",
})
@IsString()
@Matches(/^https:\/\/picsum\.photos\//, {
message: "imageUrl must use the https://picsum.photos host",
})
imageUrl!: string;
}
@@ -298,6 +302,16 @@ export class CreateOrderItemDto {
@Min(1)
@Max(20)
quantity!: number;
@ApiProperty({ example: 1, minimum: 1 })
@IsInt()
@Min(1)
expectedVersion!: number;
@ApiProperty({ example: 12990, minimum: 0 })
@IsInt()
@Min(0)
expectedUnitPriceCents!: number;
}
export class CreateOrderDto {

View File

@@ -351,21 +351,54 @@ export class SimpleStore {
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}.`,
const productIds = new Set<string>();
dto.items.forEach((item) => {
if (productIds.has(item.productId)) {
throw new UnprocessableEntityException({
code: "DUPLICATE_ORDER_PRODUCT",
message: "Each product may appear only once in an order.",
});
}
return {
productId,
productName: product.name,
quantity,
unitPriceCents: product.priceCents,
};
productIds.add(item.productId);
});
const items = dto.items.map(
({ productId, quantity, expectedVersion, expectedUnitPriceCents }) => {
const product = this.getProduct(productId);
if (
product.version !== expectedVersion ||
product.priceCents !== expectedUnitPriceCents
) {
throw new ConflictException({
code: "PRODUCT_CHANGED",
message: `${product.name} changed before checkout.`,
});
}
if (product.currency !== "USD") {
throw new UnprocessableEntityException({
code: "UNSUPPORTED_ORDER_CURRENCY",
message: "Simple API checkout accepts USD products only.",
});
}
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,
@@ -417,7 +450,7 @@ export class SimpleStore {
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`,
createdAt: "2026-07-10T09:00:00.000Z",
version: 1,
};
}

View File

@@ -9,11 +9,17 @@ import {
ApiResponse,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
ApiUnprocessableEntityResponse,
} from "@nestjs/swagger";
import { ErrorResponseDto } from "./api.dto";
export function ApiStandardErrors(
options: { auth?: boolean; notFound?: boolean; conflict?: boolean } = {},
options: {
auth?: boolean;
notFound?: boolean;
conflict?: boolean;
unprocessable?: boolean;
} = {},
) {
const decorators: Array<
ClassDecorator | MethodDecorator | PropertyDecorator
@@ -63,6 +69,15 @@ export function ApiStandardErrors(
);
}
if (options.unprocessable) {
decorators.push(
ApiUnprocessableEntityResponse({
description: "The validated request violates an order invariant.",
type: ErrorResponseDto,
}),
);
}
return applyDecorators(...decorators);
}

View File

@@ -10,8 +10,8 @@ import {
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { Observable } from "rxjs";
import { delay, map } from "rxjs/operators";
import { fromEvent, Observable, timer } from "rxjs";
import { delay, map, mergeMap, takeUntil } from "rxjs/operators";
import { DEMO_SCENARIOS, type DemoScenario } from "./api.dto";
function scenarioFromRequest(request: Request): DemoScenario {
@@ -80,9 +80,20 @@ export class ScenarioInterceptor implements NestInterceptor {
: scenario === "timeout"
? Number(process.env.MOCK_TIMEOUT_DELAY_MS ?? 30000)
: 0;
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(request.method);
const source =
configuredDelay > 0 && isMutation
? timer(configuredDelay).pipe(
takeUntil(fromEvent(response, "close")),
mergeMap(() => next.handle()),
)
: next
.handle()
.pipe(
configuredDelay > 0 ? delay(configuredDelay) : (value) => value,
);
return next.handle().pipe(
configuredDelay > 0 ? delay(configuredDelay) : (source) => source,
return source.pipe(
map((payload) => this.transformPayload(payload, scenario)),
);
}