@atlashub/smartstack-cli 4.32.0 → 4.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.documentation/index.html +2 -2
- package/.documentation/init.html +358 -174
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +271 -44
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/mcp-scaffolding/controller.cs.hbs +54 -128
- package/templates/project/README.md +19 -0
- package/templates/project/claude-md/api.CLAUDE.md.template +315 -0
- package/templates/project/claude-md/application.CLAUDE.md.template +181 -0
- package/templates/project/claude-md/domain.CLAUDE.md.template +125 -0
- package/templates/project/claude-md/infrastructure.CLAUDE.md.template +168 -0
- package/templates/project/claude-md/root.CLAUDE.md.template +339 -0
- package/templates/project/claude-md/web.CLAUDE.md.template +339 -0
- package/templates/skills/apex/SKILL.md +16 -10
- package/templates/skills/apex/_shared.md +1 -1
- package/templates/skills/apex/references/checks/architecture-checks.sh +154 -0
- package/templates/skills/apex/references/checks/backend-checks.sh +194 -0
- package/templates/skills/apex/references/checks/frontend-checks.sh +448 -0
- package/templates/skills/apex/references/checks/infrastructure-checks.sh +255 -0
- package/templates/skills/apex/references/checks/security-checks.sh +153 -0
- package/templates/skills/apex/references/checks/seed-checks.sh +536 -0
- package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +49 -192
- package/templates/skills/apex/references/post-checks.md +124 -2156
- package/templates/skills/apex/references/smartstack-api.md +160 -957
- package/templates/skills/apex/references/smartstack-frontend.md +134 -1022
- package/templates/skills/apex/references/smartstack-layers.md +12 -6
- package/templates/skills/apex/steps/step-00-init.md +81 -238
- package/templates/skills/apex/steps/step-03-execute.md +25 -752
- package/templates/skills/apex/steps/step-03a-layer0-domain.md +118 -0
- package/templates/skills/apex/steps/step-03b-layer1-seed.md +91 -0
- package/templates/skills/apex/steps/step-03c-layer2-backend.md +240 -0
- package/templates/skills/apex/steps/step-03d-layer3-frontend.md +300 -0
- package/templates/skills/apex/steps/step-03e-layer4-devdata.md +44 -0
- package/templates/skills/apex/steps/step-04-examine.md +70 -150
- package/templates/skills/application/references/frontend-i18n-and-output.md +2 -2
- package/templates/skills/application/references/frontend-route-naming.md +5 -1
- package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +49 -198
- package/templates/skills/application/references/frontend-verification.md +11 -11
- package/templates/skills/application/steps/step-05-frontend.md +26 -15
- package/templates/skills/application/templates-frontend.md +4 -0
- package/templates/skills/cli-app-sync/SKILL.md +2 -2
- package/templates/skills/cli-app-sync/references/comparison-map.md +1 -1
- package/templates/skills/controller/references/controller-code-templates.md +70 -67
- package/templates/skills/controller/references/mcp-scaffold-workflow.md +5 -1
package/package.json
CHANGED
|
@@ -1,192 +1,118 @@
|
|
|
1
|
-
using
|
|
2
|
-
using System.Collections.Generic;
|
|
3
|
-
using System.Threading;
|
|
4
|
-
using System.Threading.Tasks;
|
|
5
|
-
using Microsoft.AspNetCore.Authorization;
|
|
1
|
+
using MediatR;
|
|
6
2
|
using Microsoft.AspNetCore.Mvc;
|
|
7
|
-
using Microsoft.Extensions.Logging;
|
|
8
3
|
using SmartStack.Api.Authorization;
|
|
9
4
|
{{#if navRoute}}
|
|
10
5
|
using SmartStack.Api.Routing;
|
|
11
6
|
{{/if}}
|
|
12
|
-
using SmartStack.Application.Common.
|
|
7
|
+
using SmartStack.Application.Common.Authorization;
|
|
13
8
|
|
|
14
|
-
namespace {{namespace}}
|
|
9
|
+
namespace {{namespace}};
|
|
15
10
|
|
|
16
11
|
/// <summary>
|
|
17
|
-
/// API controller for {{name}} operations
|
|
12
|
+
/// API controller for {{name}} operations.
|
|
18
13
|
/// </summary>
|
|
19
14
|
[ApiController]
|
|
20
15
|
{{#if navRoute}}
|
|
21
|
-
[NavRoute("{{navRoute}}")]
|
|
16
|
+
[NavRoute("{{navRoute}}"{{#if navRouteSuffix}}, Suffix = "{{navRouteSuffix}}"{{/if}}{{#if customSegment}}, CustomSegment = "{{customSegment}}"{{/if}})]
|
|
22
17
|
{{else}}
|
|
23
18
|
[Route("api/[controller]")]
|
|
24
19
|
{{/if}}
|
|
25
|
-
[Authorize]
|
|
20
|
+
[Microsoft.AspNetCore.Authorization.Authorize]
|
|
26
21
|
[Produces("application/json")]
|
|
22
|
+
[Tags("{{namePlural}}")]
|
|
27
23
|
public class {{name}}Controller : ControllerBase
|
|
28
24
|
{
|
|
29
|
-
private readonly
|
|
30
|
-
// private readonly I{{name}}Service _{{nameCamel}}Service;
|
|
25
|
+
private readonly ISender _mediator;
|
|
31
26
|
|
|
32
|
-
public {{name}}Controller(
|
|
33
|
-
ILogger<{{name}}Controller> logger
|
|
34
|
-
// I{{name}}Service {{nameCamel}}Service
|
|
35
|
-
)
|
|
36
|
-
{
|
|
37
|
-
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
38
|
-
// _{{nameCamel}}Service = {{nameCamel}}Service ?? throw new ArgumentNullException(nameof({{nameCamel}}Service));
|
|
39
|
-
}
|
|
27
|
+
public {{name}}Controller(ISender mediator) => _mediator = mediator;
|
|
40
28
|
|
|
41
29
|
/// <summary>
|
|
42
30
|
/// Get all {{namePlural}} with pagination
|
|
43
31
|
/// </summary>
|
|
44
32
|
[HttpGet]
|
|
45
33
|
{{#if navRoute}}
|
|
46
|
-
[RequirePermission(
|
|
34
|
+
[RequirePermission(Permissions.{{module}}.{{namePlural}}.View)]
|
|
47
35
|
{{/if}}
|
|
48
|
-
[ProducesResponseType(typeof(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
[FromQuery] int pageSize = 20,
|
|
53
|
-
CancellationToken cancellationToken = default)
|
|
36
|
+
[ProducesResponseType(typeof(List<{{name}}ListDto>), StatusCodes.Status200OK)]
|
|
37
|
+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
38
|
+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
39
|
+
public async Task<ActionResult<List<{{name}}ListDto>>> GetAll(CancellationToken ct)
|
|
54
40
|
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// TODO: Implement using service
|
|
58
|
-
// var result = await _{{nameCamel}}Service.GetAllAsync(search, page, pageSize, cancellationToken);
|
|
59
|
-
// return Ok(result);
|
|
60
|
-
|
|
61
|
-
return Ok(PaginatedResult<{{name}}Dto>.Empty(page, pageSize));
|
|
41
|
+
var result = await _mediator.Send(new Get{{namePlural}}Query(), ct);
|
|
42
|
+
return Ok(result);
|
|
62
43
|
}
|
|
63
44
|
|
|
64
45
|
/// <summary>
|
|
65
46
|
/// Get {{nameCamel}} by ID
|
|
66
47
|
/// </summary>
|
|
67
|
-
/// <param name="id">{{name}} ID</param>
|
|
68
|
-
/// <param name="cancellationToken">Cancellation token</param>
|
|
69
|
-
/// <returns>{{name}} details</returns>
|
|
70
48
|
[HttpGet("{id:guid}")]
|
|
71
49
|
{{#if navRoute}}
|
|
72
|
-
[RequirePermission(
|
|
50
|
+
[RequirePermission(Permissions.{{module}}.{{namePlural}}.View)]
|
|
73
51
|
{{/if}}
|
|
74
|
-
[ProducesResponseType(typeof({{name}}
|
|
75
|
-
[ProducesResponseType(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
52
|
+
[ProducesResponseType(typeof({{name}}DetailDto), StatusCodes.Status200OK)]
|
|
53
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
54
|
+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
55
|
+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
56
|
+
public async Task<ActionResult<{{name}}DetailDto>> GetById(
|
|
57
|
+
Guid id, CancellationToken ct)
|
|
79
58
|
{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// var result = await _{{nameCamel}}Service.GetByIdAsync(id, cancellationToken);
|
|
84
|
-
// if (result == null) return NotFound();
|
|
85
|
-
// return Ok(result);
|
|
86
|
-
|
|
87
|
-
return NotFound();
|
|
59
|
+
var result = await _mediator.Send(new Get{{name}}ByIdQuery(id), ct);
|
|
60
|
+
if (result == null) return NotFound(new { message = "{{name}} not found" });
|
|
61
|
+
return Ok(result);
|
|
88
62
|
}
|
|
89
63
|
|
|
90
64
|
/// <summary>
|
|
91
65
|
/// Create new {{nameCamel}}
|
|
92
66
|
/// </summary>
|
|
93
|
-
/// <param name="request">Create request</param>
|
|
94
|
-
/// <param name="cancellationToken">Cancellation token</param>
|
|
95
|
-
/// <returns>Created {{nameCamel}}</returns>
|
|
96
67
|
[HttpPost]
|
|
97
68
|
{{#if navRoute}}
|
|
98
|
-
[RequirePermission(
|
|
69
|
+
[RequirePermission(Permissions.{{module}}.{{namePlural}}.Create)]
|
|
99
70
|
{{/if}}
|
|
100
|
-
[ProducesResponseType(typeof({{name}}
|
|
101
|
-
[ProducesResponseType(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
71
|
+
[ProducesResponseType(typeof({{name}}DetailDto), StatusCodes.Status201Created)]
|
|
72
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
73
|
+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
74
|
+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
75
|
+
public async Task<ActionResult<{{name}}DetailDto>> Create(
|
|
76
|
+
[FromBody] Create{{name}}Request request, CancellationToken ct)
|
|
105
77
|
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// TODO: Validate and create using service
|
|
109
|
-
// var result = await _{{nameCamel}}Service.CreateAsync(request, cancellationToken);
|
|
110
|
-
// return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
111
|
-
|
|
112
|
-
var id = Guid.NewGuid();
|
|
113
|
-
return CreatedAtAction(nameof(GetById), new { id }, null);
|
|
78
|
+
var result = await _mediator.Send(new Create{{name}}Command(request), ct);
|
|
79
|
+
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
114
80
|
}
|
|
115
81
|
|
|
116
82
|
/// <summary>
|
|
117
83
|
/// Update {{nameCamel}}
|
|
118
84
|
/// </summary>
|
|
119
|
-
/// <param name="id">{{name}} ID</param>
|
|
120
|
-
/// <param name="request">Update request</param>
|
|
121
|
-
/// <param name="cancellationToken">Cancellation token</param>
|
|
122
85
|
[HttpPut("{id:guid}")]
|
|
123
86
|
{{#if navRoute}}
|
|
124
|
-
[RequirePermission(
|
|
87
|
+
[RequirePermission(Permissions.{{module}}.{{namePlural}}.Update)]
|
|
125
88
|
{{/if}}
|
|
126
|
-
[ProducesResponseType(
|
|
127
|
-
[ProducesResponseType(
|
|
128
|
-
[ProducesResponseType(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
89
|
+
[ProducesResponseType(typeof({{name}}DetailDto), StatusCodes.Status200OK)]
|
|
90
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
91
|
+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
92
|
+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
93
|
+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
94
|
+
public async Task<ActionResult<{{name}}DetailDto>> Update(
|
|
95
|
+
Guid id, [FromBody] Update{{name}}Request request, CancellationToken ct)
|
|
133
96
|
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// await _{{nameCamel}}Service.UpdateAsync(id, request, cancellationToken);
|
|
138
|
-
|
|
139
|
-
return NoContent();
|
|
97
|
+
var result = await _mediator.Send(new Update{{name}}Command(id, request), ct);
|
|
98
|
+
if (result.IsNotFound) return NotFound(new { message = "{{name}} not found" });
|
|
99
|
+
return Ok(result.Data);
|
|
140
100
|
}
|
|
141
101
|
|
|
142
102
|
/// <summary>
|
|
143
103
|
/// Delete {{nameCamel}}
|
|
144
104
|
/// </summary>
|
|
145
|
-
/// <param name="id">{{name}} ID</param>
|
|
146
|
-
/// <param name="cancellationToken">Cancellation token</param>
|
|
147
105
|
[HttpDelete("{id:guid}")]
|
|
148
106
|
{{#if navRoute}}
|
|
149
|
-
[RequirePermission(
|
|
107
|
+
[RequirePermission(Permissions.{{module}}.{{namePlural}}.Delete)]
|
|
150
108
|
{{/if}}
|
|
151
|
-
[ProducesResponseType(
|
|
152
|
-
[ProducesResponseType(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
109
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
110
|
+
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
111
|
+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
112
|
+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
113
|
+
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|
156
114
|
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// TODO: Implement using service
|
|
160
|
-
// await _{{nameCamel}}Service.DeleteAsync(id, cancellationToken);
|
|
161
|
-
|
|
162
|
-
return NoContent();
|
|
115
|
+
var success = await _mediator.Send(new Delete{{name}}Command(id), ct);
|
|
116
|
+
return success ? NoContent() : NotFound(new { message = "{{name}} not found" });
|
|
163
117
|
}
|
|
164
118
|
}
|
|
165
|
-
|
|
166
|
-
// ============================================================================
|
|
167
|
-
// DTOs
|
|
168
|
-
// ============================================================================
|
|
169
|
-
|
|
170
|
-
/// <summary>
|
|
171
|
-
/// {{name}} data transfer object
|
|
172
|
-
/// </summary>
|
|
173
|
-
public record {{name}}Dto(
|
|
174
|
-
Guid Id,
|
|
175
|
-
DateTime CreatedAt,
|
|
176
|
-
DateTime? UpdatedAt
|
|
177
|
-
// TODO: Add additional properties
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
/// <summary>
|
|
181
|
-
/// Request to create a new {{nameCamel}}
|
|
182
|
-
/// </summary>
|
|
183
|
-
public record Create{{name}}Request(
|
|
184
|
-
// TODO: Add creation properties
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
/// <summary>
|
|
188
|
-
/// Request to update a {{nameCamel}}
|
|
189
|
-
/// </summary>
|
|
190
|
-
public record Update{{name}}Request(
|
|
191
|
-
// TODO: Add update properties
|
|
192
|
-
);
|
|
@@ -62,6 +62,25 @@ Migrations must be applied in order:
|
|
|
62
62
|
|
|
63
63
|
This is handled automatically in `Program.cs.template`.
|
|
64
64
|
|
|
65
|
+
## Frontend Setup (React)
|
|
66
|
+
|
|
67
|
+
### Route Registration
|
|
68
|
+
|
|
69
|
+
SmartStack uses **PageRegistry + DynamicRouter** for routing. Register your pages:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { PageRegistry } from '@atlashub/smartstack';
|
|
73
|
+
import { lazy } from 'react';
|
|
74
|
+
|
|
75
|
+
// Register your application pages
|
|
76
|
+
PageRegistry.register('rh.time.dashboard', lazy(() => import('./pages/TimeDashboard')));
|
|
77
|
+
PageRegistry.register('rh.time.list', lazy(() => import('./pages/TimeList')));
|
|
78
|
+
PageRegistry.register('rh.time.detail', lazy(() => import('./pages/TimeDetail')));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Navigation entries (menu, permissions, routes) are managed in the database.
|
|
82
|
+
DynamicRouter resolves everything automatically — no manual route wiring needed.
|
|
83
|
+
|
|
65
84
|
## Usage
|
|
66
85
|
|
|
67
86
|
```bash
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# {{ProjectName}}.Api - Memory
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
HTTP entry point. REST API. Receives requests, delegates to Application layer, returns responses.
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
- **{{ProjectName}}.Application** (commands, queries, DTOs)
|
|
10
|
+
- **{{ProjectName}}.Infrastructure** (DI registration)
|
|
11
|
+
- **MediatR** (sending commands/queries)
|
|
12
|
+
- **Scalar.AspNetCore** (OpenAPI UI)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## OpenAPI / Swagger Configuration (CRITICAL)
|
|
17
|
+
|
|
18
|
+
### Package Requirements
|
|
19
|
+
|
|
20
|
+
```xml
|
|
21
|
+
<!-- {{ProjectName}}.Api.csproj -->
|
|
22
|
+
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
|
23
|
+
<PackageReference Include="Scalar.AspNetCore" Version="2.0.0" />
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Program.cs Configuration
|
|
27
|
+
|
|
28
|
+
```csharp
|
|
29
|
+
// Services
|
|
30
|
+
builder.Services.AddOpenApi(options =>
|
|
31
|
+
{
|
|
32
|
+
options.AddDocumentTransformer((document, context, ct) =>
|
|
33
|
+
{
|
|
34
|
+
document.Info = new OpenApiInfo
|
|
35
|
+
{
|
|
36
|
+
Title = "{{ProjectName}} API",
|
|
37
|
+
Version = "v1",
|
|
38
|
+
Description = "{{ProjectName}} REST API"
|
|
39
|
+
};
|
|
40
|
+
return Task.CompletedTask;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Middleware (after app.Build())
|
|
45
|
+
if (app.Environment.IsDevelopment())
|
|
46
|
+
{
|
|
47
|
+
app.MapOpenApi(); // /openapi/v1.json
|
|
48
|
+
app.MapScalarApiReference(options => // /scalar/v1
|
|
49
|
+
{
|
|
50
|
+
options.WithTitle("{{ProjectName}} API")
|
|
51
|
+
.WithTheme(ScalarTheme.BluePlanet)
|
|
52
|
+
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### XML Documentation (MANDATORY for all Controllers)
|
|
58
|
+
|
|
59
|
+
```xml
|
|
60
|
+
<!-- {{ProjectName}}.Api.csproj -->
|
|
61
|
+
<PropertyGroup>
|
|
62
|
+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
63
|
+
<NoWarn>$(NoWarn);1591</NoWarn>
|
|
64
|
+
</PropertyGroup>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Controller Documentation Pattern
|
|
68
|
+
|
|
69
|
+
```csharp
|
|
70
|
+
/// <summary>
|
|
71
|
+
/// Manages orders
|
|
72
|
+
/// </summary>
|
|
73
|
+
[ApiController]
|
|
74
|
+
[Route("api/[controller]")]
|
|
75
|
+
[Produces("application/json")]
|
|
76
|
+
[Tags("Orders")]
|
|
77
|
+
public class OrdersController : ControllerBase
|
|
78
|
+
{
|
|
79
|
+
/// <summary>
|
|
80
|
+
/// Retrieves all orders with optional filtering
|
|
81
|
+
/// </summary>
|
|
82
|
+
[HttpGet]
|
|
83
|
+
[ProducesResponseType(typeof(IReadOnlyList<OrderDto>), StatusCodes.Status200OK)]
|
|
84
|
+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
|
85
|
+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
|
|
86
|
+
public async Task<IActionResult> GetAll(CancellationToken ct)
|
|
87
|
+
{
|
|
88
|
+
var result = await _mediator.Send(new GetOrdersQuery(), ct);
|
|
89
|
+
return Ok(result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### ProducesResponseType Cheat Sheet
|
|
95
|
+
|
|
96
|
+
| Scenario | Attributes |
|
|
97
|
+
|----------|------------|
|
|
98
|
+
| **GET (list)** | `[ProducesResponseType(typeof(List<Dto>), 200)]` |
|
|
99
|
+
| **GET (single)** | `[ProducesResponseType(typeof(Dto), 200)]`<br>`[ProducesResponseType(typeof(ProblemDetails), 404)]` |
|
|
100
|
+
| **POST (create)** | `[ProducesResponseType(typeof(Guid), 201)]`<br>`[ProducesResponseType(typeof(ValidationProblemDetails), 400)]` |
|
|
101
|
+
| **PUT (update)** | `[ProducesResponseType(204)]`<br>`[ProducesResponseType(typeof(ProblemDetails), 404)]` |
|
|
102
|
+
| **DELETE** | `[ProducesResponseType(204)]`<br>`[ProducesResponseType(typeof(ProblemDetails), 404)]` |
|
|
103
|
+
| **Auth required** | Add `[ProducesResponseType(typeof(ProblemDetails), 401)]` |
|
|
104
|
+
| **Permission required** | Add `[ProducesResponseType(typeof(ProblemDetails), 403)]` |
|
|
105
|
+
| **Conflict possible** | Add `[ProducesResponseType(typeof(ProblemDetails), 409)]` |
|
|
106
|
+
|
|
107
|
+
### OpenAPI URLs
|
|
108
|
+
|
|
109
|
+
| Environment | URL | Purpose |
|
|
110
|
+
|-------------|-----|---------|
|
|
111
|
+
| Development | `/openapi/v1.json` | Raw OpenAPI spec |
|
|
112
|
+
| Development | `/scalar/v1` | Interactive UI (Scalar) |
|
|
113
|
+
| Production | Disabled | Security best practice |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Structure
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
{{ProjectName}}.Api/
|
|
121
|
+
├── Controllers/ → Organized by Application/Module
|
|
122
|
+
├── Authorization/ → RequirePermissionAttribute
|
|
123
|
+
├── Middleware/
|
|
124
|
+
│ ├── GlobalExceptionHandlerMiddleware.cs
|
|
125
|
+
│ └── SessionValidationMiddleware.cs
|
|
126
|
+
├── Extensions/
|
|
127
|
+
├── RateLimiting/
|
|
128
|
+
├── Program.cs
|
|
129
|
+
├── appsettings.json
|
|
130
|
+
└── appsettings.Development.json
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Patterns
|
|
134
|
+
|
|
135
|
+
### Controller Template
|
|
136
|
+
|
|
137
|
+
```csharp
|
|
138
|
+
namespace {{ProjectName}}.Api.Controllers;
|
|
139
|
+
|
|
140
|
+
using MediatR;
|
|
141
|
+
using Microsoft.AspNetCore.Http;
|
|
142
|
+
using Microsoft.AspNetCore.Mvc;
|
|
143
|
+
using {{ProjectName}}.Api.Authorization;
|
|
144
|
+
|
|
145
|
+
[ApiController]
|
|
146
|
+
[Route("api/[controller]")]
|
|
147
|
+
[Produces("application/json")]
|
|
148
|
+
[Tags("Orders")]
|
|
149
|
+
public class OrdersController : ControllerBase
|
|
150
|
+
{
|
|
151
|
+
private readonly ISender _mediator;
|
|
152
|
+
|
|
153
|
+
public OrdersController(ISender mediator)
|
|
154
|
+
{
|
|
155
|
+
_mediator = mediator;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
[HttpGet]
|
|
159
|
+
[ProducesResponseType(typeof(IReadOnlyList<OrderDto>), StatusCodes.Status200OK)]
|
|
160
|
+
public async Task<IActionResult> GetAll(CancellationToken ct)
|
|
161
|
+
{
|
|
162
|
+
var result = await _mediator.Send(new GetOrdersQuery(), ct);
|
|
163
|
+
return Ok(result);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
[HttpGet("{id:guid}")]
|
|
167
|
+
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
|
|
168
|
+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
|
169
|
+
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
|
|
170
|
+
{
|
|
171
|
+
var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
|
|
172
|
+
return result is null ? NotFound() : Ok(result);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
[HttpPost]
|
|
176
|
+
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
|
|
177
|
+
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
|
178
|
+
public async Task<IActionResult> Create([FromBody] CreateOrderCommand command, CancellationToken ct)
|
|
179
|
+
{
|
|
180
|
+
var id = await _mediator.Send(command, ct);
|
|
181
|
+
return CreatedAtAction(nameof(GetById), new { id }, id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
[HttpPut("{id:guid}")]
|
|
185
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
186
|
+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
|
187
|
+
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOrderCommand command, CancellationToken ct)
|
|
188
|
+
{
|
|
189
|
+
if (id != command.Id) return BadRequest();
|
|
190
|
+
await _mediator.Send(command, ct);
|
|
191
|
+
return NoContent();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
[HttpDelete("{id:guid}")]
|
|
195
|
+
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
196
|
+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
|
197
|
+
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|
198
|
+
{
|
|
199
|
+
await _mediator.Send(new DeleteOrderCommand(id), ct);
|
|
200
|
+
return NoContent();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Exception Middleware
|
|
206
|
+
|
|
207
|
+
```csharp
|
|
208
|
+
namespace {{ProjectName}}.Api.Middleware;
|
|
209
|
+
|
|
210
|
+
using System.Net;
|
|
211
|
+
using FluentValidation;
|
|
212
|
+
using Microsoft.AspNetCore.Mvc;
|
|
213
|
+
using {{ProjectName}}.Application.Common.Exceptions;
|
|
214
|
+
|
|
215
|
+
public class GlobalExceptionHandlerMiddleware
|
|
216
|
+
{
|
|
217
|
+
private readonly RequestDelegate _next;
|
|
218
|
+
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
|
|
219
|
+
|
|
220
|
+
public GlobalExceptionHandlerMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlerMiddleware> logger)
|
|
221
|
+
{
|
|
222
|
+
_next = next;
|
|
223
|
+
_logger = logger;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public async Task InvokeAsync(HttpContext context)
|
|
227
|
+
{
|
|
228
|
+
try
|
|
229
|
+
{
|
|
230
|
+
await _next(context);
|
|
231
|
+
}
|
|
232
|
+
catch (ValidationException ex)
|
|
233
|
+
{
|
|
234
|
+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
235
|
+
context.Response.ContentType = "application/json";
|
|
236
|
+
var problemDetails = new ValidationProblemDetails(
|
|
237
|
+
ex.Errors.GroupBy(e => e.PropertyName)
|
|
238
|
+
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()))
|
|
239
|
+
{
|
|
240
|
+
Status = StatusCodes.Status400BadRequest,
|
|
241
|
+
Title = "Validation failed"
|
|
242
|
+
};
|
|
243
|
+
await context.Response.WriteAsJsonAsync(problemDetails);
|
|
244
|
+
}
|
|
245
|
+
catch (NotFoundException ex)
|
|
246
|
+
{
|
|
247
|
+
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
248
|
+
context.Response.ContentType = "application/json";
|
|
249
|
+
var problemDetails = new ProblemDetails
|
|
250
|
+
{
|
|
251
|
+
Title = "Not Found",
|
|
252
|
+
Detail = ex.Message,
|
|
253
|
+
Status = StatusCodes.Status404NotFound
|
|
254
|
+
};
|
|
255
|
+
await context.Response.WriteAsJsonAsync(problemDetails);
|
|
256
|
+
}
|
|
257
|
+
catch (DomainException ex)
|
|
258
|
+
{
|
|
259
|
+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
260
|
+
context.Response.ContentType = "application/json";
|
|
261
|
+
var problemDetails = new ProblemDetails
|
|
262
|
+
{
|
|
263
|
+
Title = "Business rule violation",
|
|
264
|
+
Detail = ex.Message,
|
|
265
|
+
Status = StatusCodes.Status400BadRequest
|
|
266
|
+
};
|
|
267
|
+
await context.Response.WriteAsJsonAsync(problemDetails);
|
|
268
|
+
}
|
|
269
|
+
catch (Exception ex)
|
|
270
|
+
{
|
|
271
|
+
_logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
|
|
272
|
+
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
|
273
|
+
context.Response.ContentType = "application/json";
|
|
274
|
+
var problemDetails = new ProblemDetails
|
|
275
|
+
{
|
|
276
|
+
Title = "Internal Server Error",
|
|
277
|
+
Detail = "An unexpected error occurred",
|
|
278
|
+
Status = StatusCodes.Status500InternalServerError
|
|
279
|
+
};
|
|
280
|
+
await context.Response.WriteAsJsonAsync(problemDetails);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## API Response Conventions
|
|
287
|
+
|
|
288
|
+
| Action | Status Code | Response |
|
|
289
|
+
|--------|-------------|----------|
|
|
290
|
+
| GET (list) | 200 | `List<Dto>` |
|
|
291
|
+
| GET (single) | 200 / 404 | `Dto` / ProblemDetails |
|
|
292
|
+
| POST | 201 | Id + Location header |
|
|
293
|
+
| PUT | 204 | Empty |
|
|
294
|
+
| DELETE | 204 | Empty |
|
|
295
|
+
| Validation error | 400 | ValidationProblemDetails |
|
|
296
|
+
| Server error | 500 | ProblemDetails |
|
|
297
|
+
|
|
298
|
+
## Rules
|
|
299
|
+
|
|
300
|
+
1. **Controllers are thin** - delegate to MediatR immediately
|
|
301
|
+
2. **NO business logic** in controllers
|
|
302
|
+
3. **Use `CancellationToken`** in all async methods
|
|
303
|
+
4. **Document with XML comments** for OpenAPI
|
|
304
|
+
5. **Return `IActionResult`** for flexibility
|
|
305
|
+
6. **ALWAYS add `[ProducesResponseType]`** for all possible responses
|
|
306
|
+
7. **ALWAYS add `[Tags]`** to group endpoints in OpenAPI
|
|
307
|
+
|
|
308
|
+
## When Adding New Endpoint
|
|
309
|
+
|
|
310
|
+
1. Create controller in `Controllers/` (or add to existing)
|
|
311
|
+
2. Inject `ISender` (MediatR)
|
|
312
|
+
3. Create action method with proper HTTP verb
|
|
313
|
+
4. Document with XML comments
|
|
314
|
+
5. Add `[ProducesResponseType]` attributes
|
|
315
|
+
6. Use command/query from Application layer
|