mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
feat: add example
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": false,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Check Simple API availability",
|
||||
"tags": [
|
||||
"Health"
|
||||
]
|
||||
"tags": ["Health"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
@@ -124,9 +122,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Login and receive JWT access/refresh tokens",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
@@ -228,9 +224,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Rotate a refresh token and issue a new token pair",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
@@ -305,9 +299,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Revoke a refresh token; the operation is idempotent",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me": {
|
||||
@@ -404,9 +396,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Get the authenticated user",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
"tags": ["Users"]
|
||||
}
|
||||
},
|
||||
"/api/v1/products": {
|
||||
@@ -488,12 +478,7 @@
|
||||
"schema": {
|
||||
"default": "newest",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"newest",
|
||||
"price-asc",
|
||||
"price-desc",
|
||||
"name"
|
||||
]
|
||||
"enum": ["newest", "price-asc", "price-desc", "name"]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -540,9 +525,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List products using offset pagination, filters and sorting",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "SimpleProducts_create",
|
||||
@@ -647,9 +630,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Create a product as an administrator",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
}
|
||||
},
|
||||
"/api/v1/products/{id}": {
|
||||
@@ -742,9 +723,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Get one product with ETag support",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "SimpleProducts_update",
|
||||
@@ -877,9 +856,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Update a product using optimistic locking",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SimpleProducts_remove",
|
||||
@@ -992,9 +969,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Delete a product as an administrator",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
}
|
||||
},
|
||||
"/api/v1/categories": {
|
||||
@@ -1036,9 +1011,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List product categories",
|
||||
"tags": [
|
||||
"Categories"
|
||||
]
|
||||
"tags": ["Categories"]
|
||||
}
|
||||
},
|
||||
"/api/v1/categories/{id}": {
|
||||
@@ -1128,9 +1101,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Get one category",
|
||||
"tags": [
|
||||
"Categories"
|
||||
]
|
||||
"tags": ["Categories"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders": {
|
||||
@@ -1256,9 +1227,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "List orders visible to the current user",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "SimpleOrders_create",
|
||||
@@ -1346,6 +1315,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "The validated request violates an order invariant.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Demo rate limit scenario.",
|
||||
"content": {
|
||||
@@ -1373,9 +1352,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Create an order and validate product stock",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders/{id}": {
|
||||
@@ -1490,9 +1467,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Get one visible order",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders/{id}/cancel": {
|
||||
@@ -1617,9 +1592,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Cancel an order if its state permits the transition",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/scenarios": {
|
||||
@@ -1661,9 +1634,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List deterministic X-Demo-Scenario values",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/reset": {
|
||||
@@ -1705,9 +1676,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Reset all Simple API state and token revocations",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/seed/{preset}": {
|
||||
@@ -1740,10 +1709,7 @@
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"small",
|
||||
"large"
|
||||
],
|
||||
"enum": ["small", "large"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -1761,9 +1727,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Select a small or large deterministic dataset",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/users/{userId}/role": {
|
||||
@@ -1863,9 +1827,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Change a user role to exercise dynamic access control",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1896,17 +1858,12 @@
|
||||
"properties": {
|
||||
"application": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"simple",
|
||||
"complex"
|
||||
],
|
||||
"enum": ["simple", "complex"],
|
||||
"example": "simple"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ok"
|
||||
],
|
||||
"enum": ["ok"],
|
||||
"example": "ok"
|
||||
},
|
||||
"timestamp": {
|
||||
@@ -1918,12 +1875,7 @@
|
||||
"example": "1.0.0"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"application",
|
||||
"status",
|
||||
"timestamp",
|
||||
"version"
|
||||
]
|
||||
"required": ["application", "status", "timestamp", "version"]
|
||||
},
|
||||
"HealthResponseDto": {
|
||||
"type": "object",
|
||||
@@ -1932,9 +1884,7 @@
|
||||
"$ref": "#/components/schemas/HealthDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"LoginDto": {
|
||||
"type": "object",
|
||||
@@ -1951,10 +1901,7 @@
|
||||
"minLength": 8
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
"required": ["email", "password"]
|
||||
},
|
||||
"JwtTokensDto": {
|
||||
"type": "object",
|
||||
@@ -1972,18 +1919,11 @@
|
||||
},
|
||||
"tokenType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Bearer"
|
||||
],
|
||||
"enum": ["Bearer"],
|
||||
"example": "Bearer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"expiresIn",
|
||||
"tokenType"
|
||||
]
|
||||
"required": ["accessToken", "refreshToken", "expiresIn", "tokenType"]
|
||||
},
|
||||
"SimpleUserDto": {
|
||||
"type": "object",
|
||||
@@ -2003,10 +1943,7 @@
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"customer"
|
||||
]
|
||||
"enum": ["admin", "customer"]
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "object",
|
||||
@@ -2014,13 +1951,7 @@
|
||||
"example": "https://i.pravatar.cc/160?img=12"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"email",
|
||||
"name",
|
||||
"role",
|
||||
"avatarUrl"
|
||||
]
|
||||
"required": ["id", "email", "name", "role", "avatarUrl"]
|
||||
},
|
||||
"JwtAuthDataDto": {
|
||||
"type": "object",
|
||||
@@ -2032,10 +1963,7 @@
|
||||
"$ref": "#/components/schemas/SimpleUserDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tokens",
|
||||
"user"
|
||||
]
|
||||
"required": ["tokens", "user"]
|
||||
},
|
||||
"JwtAuthResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2044,9 +1972,7 @@
|
||||
"$ref": "#/components/schemas/JwtAuthDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"ErrorDetailDto": {
|
||||
"type": "object",
|
||||
@@ -2064,9 +1990,7 @@
|
||||
"example": "isEmail"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
"required": ["message"]
|
||||
},
|
||||
"ErrorResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2121,9 +2045,7 @@
|
||||
"description": "Refresh token returned by login or the previous refresh call."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"refreshToken"
|
||||
]
|
||||
"required": ["refreshToken"]
|
||||
},
|
||||
"SimpleUserResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2132,9 +2054,7 @@
|
||||
"$ref": "#/components/schemas/SimpleUserDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"Object": {
|
||||
"type": "object",
|
||||
@@ -2166,10 +2086,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2241,12 +2158,7 @@
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"page",
|
||||
"limit",
|
||||
"total",
|
||||
"totalPages"
|
||||
]
|
||||
"required": ["page", "limit", "total", "totalPages"]
|
||||
},
|
||||
"ProductsResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2261,10 +2173,7 @@
|
||||
"$ref": "#/components/schemas/PageMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
]
|
||||
"required": ["data", "meta"]
|
||||
},
|
||||
"ProductResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2273,9 +2182,7 @@
|
||||
"$ref": "#/components/schemas/SimpleProductDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CreateProductDto": {
|
||||
"type": "object",
|
||||
@@ -2295,10 +2202,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2344,10 +2248,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2371,9 +2272,7 @@
|
||||
"description": "Version last read by the frontend."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version"
|
||||
]
|
||||
"required": ["version"]
|
||||
},
|
||||
"MutationResultDto": {
|
||||
"type": "object",
|
||||
@@ -2387,10 +2286,7 @@
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"success"
|
||||
]
|
||||
"required": ["id", "success"]
|
||||
},
|
||||
"MutationResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2399,9 +2295,7 @@
|
||||
"$ref": "#/components/schemas/MutationResultDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"SimpleCategoryDto": {
|
||||
"type": "object",
|
||||
@@ -2423,12 +2317,7 @@
|
||||
"example": 4
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"productCount"
|
||||
]
|
||||
"required": ["id", "name", "slug", "productCount"]
|
||||
},
|
||||
"CategoriesResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2440,9 +2329,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CategoryResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2451,9 +2338,7 @@
|
||||
"$ref": "#/components/schemas/SimpleCategoryDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"SimpleOrderItemDto": {
|
||||
"type": "object",
|
||||
@@ -2475,12 +2360,7 @@
|
||||
"example": 12990
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"productId",
|
||||
"productName",
|
||||
"quantity",
|
||||
"unitPriceCents"
|
||||
]
|
||||
"required": ["productId", "productName", "quantity", "unitPriceCents"]
|
||||
},
|
||||
"SimpleOrderDto": {
|
||||
"type": "object",
|
||||
@@ -2495,12 +2375,7 @@
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"paid",
|
||||
"shipped",
|
||||
"cancelled"
|
||||
]
|
||||
"enum": ["pending", "paid", "shipped", "cancelled"]
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
@@ -2514,10 +2389,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"createdAt": {
|
||||
@@ -2548,10 +2420,7 @@
|
||||
"$ref": "#/components/schemas/PageMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
]
|
||||
"required": ["data", "meta"]
|
||||
},
|
||||
"OrderResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2560,9 +2429,7 @@
|
||||
"$ref": "#/components/schemas/SimpleOrderDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CreateOrderItemDto": {
|
||||
"type": "object",
|
||||
@@ -2576,11 +2443,23 @@
|
||||
"example": 1,
|
||||
"minimum": 1,
|
||||
"maximum": 20
|
||||
},
|
||||
"expectedVersion": {
|
||||
"type": "number",
|
||||
"example": 1,
|
||||
"minimum": 1
|
||||
},
|
||||
"expectedUnitPriceCents": {
|
||||
"type": "number",
|
||||
"example": 12990,
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"productId",
|
||||
"quantity"
|
||||
"quantity",
|
||||
"expectedVersion",
|
||||
"expectedUnitPriceCents"
|
||||
]
|
||||
},
|
||||
"CreateOrderDto": {
|
||||
@@ -2593,9 +2472,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
"required": ["items"]
|
||||
},
|
||||
"ScenarioDto": {
|
||||
"type": "object",
|
||||
@@ -2609,10 +2486,7 @@
|
||||
"example": "Delays the response to exercise loading and cancellation states."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
]
|
||||
"required": ["name", "description"]
|
||||
},
|
||||
"ScenariosResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2624,9 +2498,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"TestingActionDataDto": {
|
||||
"type": "object",
|
||||
@@ -2640,10 +2512,7 @@
|
||||
"example": "State reset to the default deterministic seed."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"success",
|
||||
"message"
|
||||
]
|
||||
"required": ["success", "message"]
|
||||
},
|
||||
"TestingActionResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2652,25 +2521,18 @@
|
||||
"$ref": "#/components/schemas/TestingActionDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"ChangeSimpleRoleDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"customer"
|
||||
],
|
||||
"enum": ["admin", "customer"],
|
||||
"example": "customer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role"
|
||||
]
|
||||
"required": ["role"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
examples/demo-backend/package-lock.json
generated
12
examples/demo-backend/package-lock.json
generated
@@ -3020,9 +3020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4685,9 +4685,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start:dev": "concurrently -k -n simple,complex -c blue,magenta \"npm:dev:simple\" \"npm:dev:complex\"",
|
||||
"dev:simple": "nest start --watch --entryFile apps/simple/main",
|
||||
"dev:complex": "nest start --watch --entryFile apps/complex/main",
|
||||
"dev:simple": "nest start --watch --path tsconfig.build.json --entryFile apps/simple/main",
|
||||
"dev:complex": "nest start --watch --path tsconfig.build.json --entryFile apps/complex/main",
|
||||
"start:simple": "node dist/apps/simple/main.js",
|
||||
"start:complex": "node dist/apps/complex/main.js",
|
||||
"openapi:generate": "npm run build && node dist/scripts/generate-openapi.js",
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,9 +151,137 @@ describe("Simple API", () => {
|
||||
const order = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.send({ items: [{ productId: "product-keyboard", quantity: 1 }] })
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(201);
|
||||
expect(order.body.data.userId).toBe("user-customer");
|
||||
expect(order.body.data.totalCents).toBe(12990);
|
||||
});
|
||||
|
||||
it("does not execute a timeout mutation after the client disconnects", async () => {
|
||||
const customer = await login("customer@demo.local");
|
||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
const ordersBefore = await request(app.getHttpServer())
|
||||
.get("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.expect(200);
|
||||
const previousDelay = process.env.MOCK_TIMEOUT_DELAY_MS;
|
||||
|
||||
process.env.MOCK_TIMEOUT_DELAY_MS = "100";
|
||||
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.set("X-Demo-Scenario", "timeout")
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
},
|
||||
],
|
||||
})
|
||||
.timeout({ deadline: 10 });
|
||||
} catch {
|
||||
// The client intentionally closes the response before the mutation delay elapses.
|
||||
} finally {
|
||||
if (previousDelay === undefined) {
|
||||
delete process.env.MOCK_TIMEOUT_DELAY_MS;
|
||||
} else {
|
||||
process.env.MOCK_TIMEOUT_DELAY_MS = previousDelay;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const ordersAfter = await request(app.getHttpServer())
|
||||
.get("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.expect(200);
|
||||
|
||||
expect(ordersAfter.body.meta.total).toBe(ordersBefore.body.meta.total);
|
||||
});
|
||||
|
||||
it("rejects an order when the confirmed product snapshot changed", async () => {
|
||||
const customer = await login("customer@demo.local");
|
||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.code).toBe("PRODUCT_CHANGED");
|
||||
});
|
||||
|
||||
it("rejects duplicate product lines and unsupported order currency", async () => {
|
||||
const admin = await login();
|
||||
const adminAuthorization = `Bearer ${admin.body.data.tokens.accessToken as string}`;
|
||||
const customer = await login("customer@demo.local");
|
||||
const customerAuthorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
const line = {
|
||||
productId: "product-keyboard",
|
||||
quantity: 20,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
};
|
||||
const duplicateResponse = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", customerAuthorization)
|
||||
.send({ items: [line, line] })
|
||||
.expect(422);
|
||||
|
||||
expect(duplicateResponse.body.code).toBe("DUPLICATE_ORDER_PRODUCT");
|
||||
|
||||
const eurProduct = await request(app.getHttpServer())
|
||||
.post("/api/v1/products")
|
||||
.set("Authorization", adminAuthorization)
|
||||
.send({
|
||||
name: "Euro Product",
|
||||
description: "A deterministic product priced in euros.",
|
||||
priceCents: 1000,
|
||||
currency: "EUR",
|
||||
categoryId: "category-books",
|
||||
stock: 5,
|
||||
imageUrl: "https://picsum.photos/seed/euro-product/640/480",
|
||||
})
|
||||
.expect(201);
|
||||
const currencyResponse = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", customerAuthorization)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: eurProduct.body.data.id,
|
||||
quantity: 1,
|
||||
expectedVersion: eurProduct.body.data.version,
|
||||
expectedUnitPriceCents: eurProduct.body.data.priceCents,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(currencyResponse.body.code).toBe("UNSUPPORTED_ORDER_CURRENCY");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
|
||||
Reference in New Issue
Block a user