mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 15:30:16 +03:00
feat: add example
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user