Created
May 9, 2025 02:11
-
-
Save leonj1/64c894b154a40884fbbebfbc2c59f46c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Okay, this is a very detailed request! For each of the 20 steps per endpoint, providing an example "payload" can mean different things: | |
* For DTO definition steps, the "payload" is the C# code of the DTO. | |
* For controller action/service method steps, it could be a snippet of the C# code or an example HTTP request/response if it's a test step. | |
* For validation steps, it's typically an invalid HTTP request payload and the expected error response. | |
I will provide these for the first four endpoints as you listed previously (`GET /stores`, `POST /stores`, `GET /stores/{store_id}`, `DELETE /stores/{store_id}`). This will be quite extensive. | |
**Important Note on Payloads:** | |
* Dates in JSON responses are illustrative and should reflect actual `DateTime.UtcNow` or similar. | |
* `continuation_token` values are placeholders. Real tokens would be opaque strings generated by the persistence layer. | |
* Error codes (e.g., `validation_error`) should map to the defined enums (`ErrorCode`, `AuthErrorCode`, etc.). | |
* The `Store` DTO and common error DTOs are assumed to be defined as per the preliminary step in the previous response. | |
--- | |
## Endpoint: GET /stores (ListStores) | |
### Step 1: Create Stores Controller and ListStores Action Method | |
* **Goal:** Set up the basic structure for the `StoresController` and the `ListStores` action method. | |
* **Example "Payload" (C# Code Snippet - `StoresController.cs` initial structure):** | |
```csharp | |
// StoresController.cs | |
using Microsoft.AspNetCore.Mvc; | |
using System.Threading.Tasks; | |
using System.Collections.Generic; // For List | |
using Microsoft.AspNetCore.Http; // For StatusCodes | |
// Assuming DTOs like ListStoresResponse and Store are in a Models namespace | |
// using YourProject.Models; | |
[ApiController] | |
public class StoresController : ControllerBase | |
{ | |
// Placeholder for IStoreService dependency | |
// private readonly IStoreService _storeService; | |
// public StoresController(IStoreService storeService) { _storeService = storeService; } | |
[HttpGet("/stores")] | |
[Produces("application/json")] | |
public async Task<IActionResult> ListStores( | |
[FromQuery(Name = "page_size")] int? pageSize, | |
[FromQuery(Name = "continuation_token")] string continuationToken, | |
[FromQuery] string name) | |
{ | |
// Dummy implementation for now | |
await Task.CompletedTask; | |
var dummyStores = new List<Store> { new Store { Id = "dummy-id", Name = "Dummy Store", CreatedAt = System.DateTime.UtcNow, UpdatedAt = System.DateTime.UtcNow }}; | |
return Ok(new ListStoresResponse { Stores = dummyStores, ContinuationToken = "dummy-token" }); | |
} | |
} | |
``` | |
### Step 2: Define `ListStoresResponse` DTO | |
* **Goal:** Create the C# DTO for the successful response body. | |
* **Example "Payload" (C# Code Snippet - `ListStoresResponse.cs`):** | |
```csharp | |
// Models/ListStoresResponse.cs | |
using System.Collections.Generic; | |
using System.Text.Json.Serialization; | |
public class ListStoresResponse | |
{ | |
[JsonPropertyName("stores")] | |
public List<Store> Stores { get; set; } | |
[JsonPropertyName("continuation_token")] | |
public string ContinuationToken { get; set; } | |
} | |
``` | |
### Step 3: Define `Store` DTO | |
* **Goal:** Create the C# DTO for individual store items. | |
* **Example "Payload" (C# Code Snippet - `Store.cs`):** | |
```csharp | |
// Models/Store.cs | |
using System; | |
using System.Text.Json.Serialization; | |
public class Store | |
{ | |
[JsonPropertyName("id")] | |
public string Id { get; set; } | |
[JsonPropertyName("name")] | |
public string Name { get; set; } | |
[JsonPropertyName("created_at")] | |
public DateTime CreatedAt { get; set; } | |
[JsonPropertyName("updated_at")] | |
public DateTime UpdatedAt { get; set; } | |
[JsonPropertyName("deleted_at")] | |
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // Important for optional fields | |
public DateTime? DeletedAt { get; set; } | |
} | |
``` | |
### Step 4: Define `IStoreService` Interface | |
* **Goal:** Define an interface for the business logic layer. | |
* **Example "Payload" (C# Code Snippet - `IStoreService.cs`):** | |
```csharp | |
// Services/IStoreService.cs | |
using System.Collections.Generic; | |
using System.Threading.Tasks; | |
// using YourProject.Models; // Assuming Store DTO is here | |
public interface IStoreService | |
{ | |
Task<(List<Store> stores, string continuationToken)> ListStoresAsync(int? pageSize, string continuationToken, string nameFilter); | |
} | |
``` | |
### Step 5: Implement Mock `StoreService` for Happy Path | |
* **Goal:** Create a basic implementation of `IStoreService`. | |
* **Example "Payload" (C# Code Snippet - `MockStoreService.cs`):** | |
```csharp | |
// Services/MockStoreService.cs | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Threading.Tasks; | |
// using YourProject.Models; | |
public class MockStoreService : IStoreService | |
{ | |
private static readonly List<Store> MockData = new List<Store> | |
{ | |
new Store { Id = "01GKNSYMQ4KX71S0N0Z250BZ5X", Name = "FGA Test Store", CreatedAt = DateTime.UtcNow.AddDays(-5), UpdatedAt = DateTime.UtcNow.AddDays(-1) }, | |
new Store { Id = "01GKNT00ABCD1S0N0Z250CDEFG", Name = "Sample Store", CreatedAt = DateTime.UtcNow.AddDays(-10), UpdatedAt = DateTime.UtcNow.AddDays(-2) }, | |
new Store { Id = "01GKNT01EFGH1S0N0Z250HIJKL", Name = "Another Store", CreatedAt = DateTime.UtcNow.AddDays(-2), UpdatedAt = DateTime.UtcNow.AddDays(-0) } | |
}; | |
public Task<(List<Store> stores, string continuationToken)> ListStoresAsync(int? pageSize, string currentContinuationToken, string nameFilter) | |
{ | |
var query = MockData.AsEnumerable(); | |
if (!string.IsNullOrEmpty(nameFilter)) | |
{ | |
query = query.Where(s => s.Name != null && s.Name.Equals(nameFilter, StringComparison.OrdinalIgnoreCase)); | |
} | |
// Simple offset-based pagination for mock | |
int skip = 0; | |
if (!string.IsNullOrEmpty(currentContinuationToken) && int.TryParse(currentContinuationToken, out int parsedSkip)) | |
{ | |
skip = parsedSkip; | |
} | |
var results = query.Skip(skip).ToList(); | |
int actualPageSize = pageSize.HasValue && pageSize.Value > 0 ? pageSize.Value : 10; // Default to 10 | |
var page = results.Take(actualPageSize).ToList(); | |
string nextContinuationToken = null; | |
if (results.Count > actualPageSize) | |
{ | |
nextContinuationToken = (skip + actualPageSize).ToString(); | |
} | |
return Task.FromResult((page, nextContinuationToken)); | |
} | |
} | |
``` | |
### Step 6: Inject `IStoreService` into `StoresController` | |
* **Goal:** Set up dependency injection. | |
* **Example "Payload" (C# Code Snippet - `StoresController.cs` constructor and `Program.cs` / `Startup.cs`):** | |
```csharp | |
// StoresController.cs (relevant part) | |
private readonly IStoreService _storeService; | |
public StoresController(IStoreService storeService) // Constructor injection | |
{ | |
_storeService = storeService; | |
} | |
// Program.cs (for .NET 6+) | |
// builder.Services.AddScoped<IStoreService, MockStoreService>(); | |
// Or Startup.cs (ConfigureServices method for older .NET) | |
// services.AddScoped<IStoreService, MockStoreService>(); | |
``` | |
### Step 7: Implement Controller Logic for Basic Happy Path (No Query Params) | |
* **Goal:** Call the service and return a 200 OK response. | |
* **Controller Code in `ListStores`:** | |
```csharp | |
// StoresController.cs - ListStores method | |
public async Task<IActionResult> ListStores( | |
[FromQuery(Name = "page_size")] int? pageSize, | |
[FromQuery(Name = "continuation_token")] string continuationToken, | |
[FromQuery] string name) | |
{ | |
var (stores, nextToken) = await _storeService.ListStoresAsync(pageSize, continuationToken, name); | |
var response = new ListStoresResponse | |
{ | |
Stores = stores, | |
ContinuationToken = nextToken ?? "" // Ensure continuation token is empty string if null | |
}; | |
return Ok(response); | |
} | |
``` | |
* **Example HTTP Request:** | |
`GET /stores` | |
`Accept: application/json` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"stores": [ | |
{ | |
"id": "01GKNSYMQ4KX71S0N0Z250BZ5X", | |
"name": "FGA Test Store", | |
"created_at": "2023-10-22T12:30:45.123Z", // Example UTC DateTime | |
"updated_at": "2023-10-26T15:00:00.000Z" | |
}, | |
{ | |
"id": "01GKNT00ABCD1S0N0Z250CDEFG", | |
"name": "Sample Store", | |
"created_at": "2023-10-17T08:00:00.000Z", | |
"updated_at": "2023-10-25T10:20:30.456Z" | |
} | |
// ... potentially more stores up to default page size ... | |
], | |
"continuation_token": "2" // Example if page_size was 2 and there are more | |
} | |
``` | |
### Step 8: Handle `page_size` Query Parameter | |
* **Goal:** Read `page_size` and pass to service. | |
* **Example HTTP Request:** | |
`GET /stores?page_size=1` | |
`Accept: application/json` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"stores": [ | |
{ | |
"id": "01GKNSYMQ4KX71S0N0Z250BZ5X", | |
"name": "FGA Test Store", | |
"created_at": "2023-10-22T12:30:45.123Z", | |
"updated_at": "2023-10-26T15:00:00.000Z" | |
} | |
], | |
"continuation_token": "1" // Next skip would be 1 | |
} | |
``` | |
### Step 9: Handle `continuation_token` Query Parameter | |
* **Goal:** Read `continuation_token` and pass to service. | |
* **Example HTTP Request:** | |
`GET /stores?page_size=1&continuation_token=1` | |
`Accept: application/json` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"stores": [ | |
{ | |
"id": "01GKNT00ABCD1S0N0Z250CDEFG", | |
"name": "Sample Store", | |
"created_at": "2023-10-17T08:00:00.000Z", | |
"updated_at": "2023-10-25T10:20:30.456Z" | |
} | |
], | |
"continuation_token": "2" // Next skip would be 2 | |
} | |
``` | |
### Step 10: Handle `name` Query Parameter | |
* **Goal:** Read `name` and pass to service for filtering. | |
* **Example HTTP Request:** | |
`GET /stores?name=Sample%20Store` | |
`Accept: application/json` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"stores": [ | |
{ | |
"id": "01GKNT00ABCD1S0N0Z250CDEFG", | |
"name": "Sample Store", | |
"created_at": "2023-10-17T08:00:00.000Z", | |
"updated_at": "2023-10-25T10:20:30.456Z" | |
} | |
], | |
"continuation_token": "" // Assuming only one store matches this name | |
} | |
``` | |
### Step 11: Implement 200 OK Response Type Attribute | |
* **Goal:** Add `ProducesResponseType` attribute for 200 OK. | |
* **Example "Payload" (C# Code Snippet - `StoresController.cs` decoration):** | |
```csharp | |
[HttpGet("/stores")] | |
[Produces("application/json")] | |
[ProducesResponseType(typeof(ListStoresResponse), StatusCodes.Status200OK)] // This line | |
// ... other ProducesResponseType attributes for errors ... | |
public async Task<IActionResult> ListStores( /* ... params ... */ ) {/* ... */} | |
``` | |
### Step 12: Implement Input Validation for `page_size` | |
* **Goal:** Add validation for `page_size`. | |
* **Controller Code Snippet (Conceptual Validation, before service call):** | |
```csharp | |
if (pageSize.HasValue && (pageSize.Value <= 0 || pageSize.Value > 100)) // Example max 100 | |
{ | |
return BadRequest(new ValidationErrorMessageResponse | |
{ | |
Code = ErrorCode.ValidationError, // From your ErrorCode enum | |
Message = "page_size must be an integer between 1 and 100." | |
}); | |
} | |
``` | |
* **Example HTTP Request (Invalid):** | |
`GET /stores?page_size=0` | |
`Accept: application/json` | |
* **Example HTTP Response (400 Bad Request):** | |
```json | |
{ | |
"code": "validation_error", // Assuming ErrorCode.ValidationError maps to this string | |
"message": "page_size must be an integer between 1 and 100." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(ValidationErrorMessageResponse), StatusCodes.Status400BadRequest)]` | |
### Step 13: Implement Input Validation for `continuation_token` (Optional) | |
* **Goal:** Add validation for `continuation_token` format if applicable. | |
* **Controller Code Snippet (Conceptual):** | |
```csharp | |
// if (!string.IsNullOrEmpty(continuationToken) && !IsValidBase64(continuationToken)) // Example | |
// { | |
// return BadRequest(new ValidationErrorMessageResponse { Code = ErrorCode.InvalidContinuationToken, Message = "Invalid continuation_token format." }); | |
// } | |
``` | |
* **Example HTTP Request (Potentially Invalid):** | |
`GET /stores?continuation_token=!!!INVALID_TOKEN_CHARS!!!` | |
* **Example HTTP Response (400 Bad Request if validation fails):** | |
```json | |
{ | |
"code": "invalid_continuation_token", | |
"message": "Invalid continuation_token format." | |
} | |
``` | |
### Step 14: Implement 401 Unauthenticated Response | |
* **Goal:** Ensure 401 for unauthenticated requests. | |
* **Example HTTP Request (No Auth):** | |
`GET /stores` | |
(No `Authorization` header or invalid/expired token) | |
* **Example HTTP Response (401 Unauthorized, typically from auth middleware):** | |
```json | |
{ | |
"code": "unauthenticated", // From ErrorCode enum | |
"message": "Not authenticated." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(UnauthenticatedResponse), StatusCodes.Status401Unauthorized)]` | |
### Step 15: Implement 403 Forbidden Response | |
* **Goal:** Ensure 403 for unauthorized users. | |
* **Example HTTP Request (Authenticated, but Lacks Permission):** | |
`GET /stores` | |
`Authorization: Bearer valid_token_for_user_without_list_stores_permission` | |
* **Example HTTP Response (403 Forbidden, from authorization logic):** | |
```json | |
{ | |
"code": "forbidden", // From AuthErrorCode enum | |
"message": "Forbidden." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(ForbiddenResponse), StatusCodes.Status403Forbidden)]` | |
### Step 16: Implement 404 PathUnknownErrorMessageResponse | |
* **Goal:** Correct 404 for incorrect paths (usually framework). | |
* **Example HTTP Request:** | |
`GET /nonexistentstores` | |
* **Example HTTP Response (404 Not Found, example body):** | |
```json | |
{ | |
"code": "undefined_endpoint", // From NotFoundErrorCode enum | |
"message": "Request failed due to incorrect path." | |
} | |
``` | |
* **ProducesResponseType attribute (for completeness on `ListStores`):** | |
`[ProducesResponseType(typeof(PathUnknownErrorMessageResponse), StatusCodes.Status404NotFound)]` | |
### Step 17: Implement 409 AbortedMessageResponse | |
* **Goal:** Handle transaction conflicts. | |
* **Controller Code (Conceptual try-catch):** | |
```csharp | |
// try { ... service call ... } | |
// catch (TransactionConflictException ex) // Your custom exception | |
// { | |
// return StatusCode(StatusCodes.Status409Conflict, new AbortedMessageResponse { Code = "10", Message = "transaction conflict" }); | |
// } | |
``` | |
* **Example HTTP Response (409 Conflict, if service throws):** | |
```json | |
{ | |
"code": "10", // As per spec example | |
"message": "transaction conflict" | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(AbortedMessageResponse), StatusCodes.Status409Conflict)]` | |
### Step 18: Implement 422 UnprocessableContentMessageResponse | |
* **Goal:** Handle request throttling. | |
* **Controller Code (Conceptual try-catch):** | |
```csharp | |
// try { ... service call ... } | |
// catch (ThrottlingException ex) // Your custom exception | |
// { | |
// return StatusCode(StatusCodes.Status422UnprocessableEntity, new UnprocessableContentMessageResponse { Code = UnprocessableContentErrorCode.ThrottledTimeoutError, Message = "Request timed out due to excessive request throttling." }); | |
// } | |
``` | |
* **Example HTTP Response (422, if service throws):** | |
```json | |
{ | |
"code": "throttled_timeout_error", | |
"message": "Request timed out due to excessive request throttling." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(UnprocessableContentMessageResponse), StatusCodes.Status422UnprocessableEntity)]` | |
### Step 19: Implement 500 InternalErrorMessageResponse | |
* **Goal:** Handle unexpected errors. | |
* **Controller Code (Conceptual try-catch or global handler):** | |
```csharp | |
// try { ... service call ... } | |
// catch (Exception ex) { /* log ex */ return StatusCode(StatusCodes.Status500InternalServerError, new InternalErrorMessageResponse { Code = InternalErrorCode.InternalError, Message = "Request failed due to internal server error." }); } | |
``` | |
* **Example HTTP Response (500, if service throws unhandled exception):** | |
```json | |
{ | |
"code": "internal_error", | |
"message": "Request failed due to internal server error." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(InternalErrorMessageResponse), StatusCodes.Status500InternalServerError)]` | |
### Step 20: Add XML Documentation Comments | |
* **Goal:** Add /// XML comments for Swagger. | |
* **Example "Payload" (C# Code Snippet with XML comments):** | |
```csharp | |
/// <summary> | |
/// List all stores. | |
/// </summary> | |
/// <remarks> | |
/// Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. | |
/// The continuation token will be empty if there are no more stores. | |
/// </remarks> | |
/// <param name="pageSize">Optional. The maximum number of stores to return. Defaults to a server-defined limit.</param> | |
/// <param name="continuationToken">Optional. A token from a previous response to fetch the next page of stores.</param> | |
/// <param name="name">Optional. Filter stores by exact name match.</param> | |
/// <response code="200">A successful response containing a list of stores and a continuation token.</response> | |
/// <response code="400">Request failed due to invalid input (e.g., invalid page_size).</response> | |
/// <response code="401">Not authenticated.</response> | |
// ... other response codes documented ... | |
[HttpGet("/stores")] | |
// ... attributes ... | |
public async Task<IActionResult> ListStores(/*...params...*/) {/*...*/} | |
``` | |
--- | |
## Endpoint: POST /stores (CreateStore) | |
### Step 1: Add CreateStore Action Method to StoresController | |
* **Goal:** Define the `CreateStore` action method. | |
* **Example "Payload" (C# Code Snippet - in `StoresController.cs`):** | |
```csharp | |
[HttpPost("/stores")] | |
[Consumes("application/json")] | |
[Produces("application/json")] | |
public async Task<IActionResult> CreateStore([FromBody] CreateStoreRequest body) | |
{ | |
// Dummy implementation for now | |
await Task.CompletedTask; | |
var createdStore = new CreateStoreResponse | |
{ | |
Id = "newly-created-id", | |
Name = body.Name, | |
CreatedAt = DateTime.UtcNow, | |
UpdatedAt = DateTime.UtcNow | |
}; | |
return StatusCode(StatusCodes.Status201Created, createdStore); | |
// Proper implementation would use: | |
// return CreatedAtAction(nameof(GetStore), new { storeId = createdStore.Id }, createdStore); | |
// Assuming GetStore action exists | |
} | |
``` | |
### Step 2: Define `CreateStoreRequest` DTO | |
* **Goal:** Create the C# DTO for the request body. | |
* **Example "Payload" (C# Code Snippet - `CreateStoreRequest.cs`):** | |
```csharp | |
// Models/CreateStoreRequest.cs | |
using System.ComponentModel.DataAnnotations; // For validation attributes | |
using System.Text.Json.Serialization; | |
public class CreateStoreRequest | |
{ | |
[JsonPropertyName("name")] | |
[Required(ErrorMessage = "Store name is required.")] | |
[StringLength(100, MinimumLength = 3, ErrorMessage = "Store name must be between 3 and 100 characters.")] | |
// Add [RegularExpression] if specific pattern is needed for store names | |
public string Name { get; set; } | |
} | |
``` | |
### Step 3: Define `CreateStoreResponse` DTO | |
* **Goal:** Create the C# DTO for the 201 response body. | |
* **Example "Payload" (C# Code Snippet - `CreateStoreResponse.cs`):** | |
```csharp | |
// Models/CreateStoreResponse.cs | |
using System; | |
using System.Text.Json.Serialization; | |
public class CreateStoreResponse | |
{ | |
[JsonPropertyName("id")] | |
public string Id { get; set; } | |
[JsonPropertyName("name")] | |
public string Name { get; set; } | |
[JsonPropertyName("created_at")] | |
public DateTime CreatedAt { get; set; } | |
[JsonPropertyName("updated_at")] | |
public DateTime UpdatedAt { get; set; } | |
} | |
``` | |
### Step 4: Extend `IStoreService` Interface | |
* **Goal:** Add method for creating a store. | |
* **Example "Payload" (C# Code Snippet - in `IStoreService.cs`):** | |
```csharp | |
// Task<CreateStoreResponse> CreateStoreAsync(CreateStoreRequest request); | |
// Or if service deals with domain models: | |
Task<Store> CreateStoreAsync(string name); // Then controller maps Store to CreateStoreResponse | |
``` | |
Let's assume the service returns the response DTO for simplicity here: | |
```csharp | |
Task<CreateStoreResponse> CreateStoreAsync(CreateStoreRequest request); | |
``` | |
### Step 5: Implement Mock `StoreService.CreateStoreAsync` | |
* **Goal:** Add mock implementation. | |
* **Example "Payload" (C# Code Snippet - in `MockStoreService.cs`):** | |
```csharp | |
public Task<CreateStoreResponse> CreateStoreAsync(CreateStoreRequest request) | |
{ | |
// Simulate ID generation (e.g., ULID or GUID) | |
string newId = $"01HMOCKID{Guid.NewGuid().ToString().Substring(0, 8).ToUpper()}"; | |
var newStore = new Store | |
{ | |
Id = newId, | |
Name = request.Name, | |
CreatedAt = DateTime.UtcNow, | |
UpdatedAt = DateTime.UtcNow | |
}; | |
MockData.Add(newStore); // Add to the static list for consistency in mock | |
var response = new CreateStoreResponse | |
{ | |
Id = newStore.Id, | |
Name = newStore.Name, | |
CreatedAt = newStore.CreatedAt, | |
UpdatedAt = newStore.UpdatedAt | |
}; | |
return Task.FromResult(response); | |
} | |
``` | |
### Step 6: Implement Controller Logic for Happy Path (201 Created) | |
* **Goal:** Call service, return 201 Created. | |
* **Controller Code in `CreateStore`:** | |
```csharp | |
// Update CreateStore method in StoresController | |
public async Task<IActionResult> CreateStore([FromBody] CreateStoreRequest body) | |
{ | |
if (!ModelState.IsValid) // Check for validation errors from DTO attributes | |
{ | |
// This will be covered more in step 7, but good to have early | |
return BadRequest(ModelState); | |
} | |
var createdStoreResponse = await _storeService.CreateStoreAsync(body); | |
// Assuming a GetStore action exists for the Location header | |
return CreatedAtAction("GetStore", new { storeId = createdStoreResponse.Id }, createdStoreResponse); | |
} | |
``` | |
* **Example HTTP Request:** | |
`POST /stores` | |
`Content-Type: application/json` | |
`Accept: application/json` | |
```json | |
{ | |
"name": "My New Awesome Store" | |
} | |
``` | |
* **Example HTTP Response (201 Created):** | |
`Status: 201 Created` | |
`Location: /stores/01HMOCKIDABCDEFGH` (URL to the newly created resource) | |
```json | |
{ | |
"id": "01HMOCKIDABCDEFGH", // Example new ID | |
"name": "My New Awesome Store", | |
"created_at": "2023-10-27T10:00:00.000Z", | |
"updated_at": "2023-10-27T10:00:00.000Z" | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(CreateStoreResponse), StatusCodes.Status201Created)]` | |
### Step 7: Implement Request Body Validation (`CreateStoreRequest`) | |
* **Goal:** Ensure request body is validated. | |
* **Controller Code:** (Already partially shown in Step 6 with `ModelState.IsValid`) | |
If `CreateStoreRequest` has `[Required]` and `[StringLength]` on `Name`, ASP.NET Core handles this. | |
* **Example HTTP Request (Invalid - missing name):** | |
`POST /stores` | |
`Content-Type: application/json` | |
`Accept: application/json` | |
```json | |
{} | |
``` | |
* **Example HTTP Response (400 Bad Request, from model validation):** | |
```json | |
{ | |
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", // Standard problem details | |
"title": "One or more validation errors occurred.", | |
"status": 400, | |
"errors": { | |
"Name": [ | |
"Store name is required." | |
] | |
} | |
} | |
``` | |
*(To match the spec's `ValidationErrorMessageResponse`, you might need a custom error filter or manually construct it.)* | |
If manually constructing: | |
```csharp | |
// In CreateStore, if !ModelState.IsValid | |
// var errorResponse = new ValidationErrorMessageResponse { Code = ErrorCode.ValidationError, Message = "Validation failed." /* Or more specific */ }; | |
// Collect errors from ModelState if needed for message. | |
// return BadRequest(errorResponse); | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(ValidationErrorMessageResponse), StatusCodes.Status400BadRequest)]` | |
### Step 8: Handle Store Name Conflicts (Conceptual - if names are unique) | |
* **Goal:** Handle if service indicates a name conflict. | |
* **Mock Service Logic Update (Conceptual):** | |
```csharp | |
// In MockStoreService.CreateStoreAsync, before creating: | |
// if (MockData.Any(s => s.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase))) | |
// { | |
// throw new StoreNameConflictException($"Store with name '{request.Name}' already exists."); | |
// } | |
``` | |
* **Controller Code (Conceptual try-catch):** | |
```csharp | |
// try { ... service call ... } | |
// catch (StoreNameConflictException ex) // Custom exception | |
// { | |
// // The spec uses 409 for "transaction conflict". A "resource already exists" is often 409 too. | |
// return StatusCode(StatusCodes.Status409Conflict, new AbortedMessageResponse { Code = "resource_already_exists_placeholder", Message = ex.Message }); | |
// } | |
``` | |
* **Example HTTP Request (Conflicting Name):** | |
`POST /stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "FGA Test Store" } // Assuming this name already exists | |
``` | |
* **Example HTTP Response (409 Conflict):** | |
```json | |
{ | |
"code": "resource_already_exists_placeholder", // Or "10" if mapped generally | |
"message": "Store with name 'FGA Test Store' already exists." | |
} | |
``` | |
### Step 9: Implement 401 Unauthenticated Response | |
* **Goal:** Ensure 401 for unauthenticated requests. | |
* **Example HTTP Request (No Auth):** | |
`POST /stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "Store Attempt" } | |
``` | |
* **Example HTTP Response (401 Unauthorized):** | |
```json | |
{ "code": "unauthenticated", "message": "Not authenticated." } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(UnauthenticatedResponse), StatusCodes.Status401Unauthorized)]` | |
### Step 10: Implement 403 Forbidden Response | |
* **Goal:** Ensure 403 for unauthorized users. | |
* **Example HTTP Request (Auth, No Permission):** | |
`POST /stores` | |
`Authorization: Bearer valid_token_user_cannot_create_stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "Forbidden Store" } | |
``` | |
* **Example HTTP Response (403 Forbidden):** | |
```json | |
{ "code": "forbidden", "message": "Forbidden." } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(ForbiddenResponse), StatusCodes.Status403Forbidden)]` | |
### Step 11: Implement 404 PathUnknownErrorMessageResponse (Framework Level) | |
* **Goal:** Ensure 404 for incorrect paths. | |
* **Example HTTP Request:** | |
`POST /nonexistentstores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "Lost Store" } | |
``` | |
* **Example HTTP Response (404 Not Found):** | |
```json | |
{ "code": "undefined_endpoint", "message": "Request failed due to incorrect path." } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(PathUnknownErrorMessageResponse), StatusCodes.Status404NotFound)]` | |
### Step 12: Implement 409 AbortedMessageResponse for Transaction Conflicts | |
* **Same as Step 17 for GET /stores, but in the context of `CreateStoreAsync` call.** | |
* **Example HTTP Response (409 Conflict):** | |
```json | |
{ "code": "10", "message": "transaction conflict" } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(AbortedMessageResponse), StatusCodes.Status409Conflict)]` | |
### Step 13: Implement 422 UnprocessableContentMessageResponse for Throttling | |
* **Same as Step 18 for GET /stores, but in the context of `CreateStoreAsync` call.** | |
* **Example HTTP Response (422 Unprocessable Entity):** | |
```json | |
{ "code": "throttled_timeout_error", "message": "Request timed out due to excessive request throttling." } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(UnprocessableContentMessageResponse), StatusCodes.Status422UnprocessableEntity)]` | |
### Step 14: Implement 500 InternalErrorMessageResponse | |
* **Same as Step 19 for GET /stores, but in the context of `CreateStoreAsync` call.** | |
* **Example HTTP Response (500 Internal Server Error):** | |
```json | |
{ "code": "internal_error", "message": "Request failed due to internal server error." } | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(InternalErrorMessageResponse), StatusCodes.Status500InternalServerError)]` | |
### Step 15: Refine `CreateStoreRequest` Name Validation | |
* **Goal:** Apply specific constraints to `Name`. | |
* **Example (C# in `CreateStoreRequest.cs`):** | |
```csharp | |
[JsonPropertyName("name")] | |
[Required] | |
[StringLength(64, MinimumLength = 3)] | |
[RegularExpression("^[a-zA-Z0-9_-]+$", ErrorMessage = "Store name can only contain alphanumeric characters, underscores, and hyphens.")] | |
public string Name { get; set; } | |
``` | |
* **Example HTTP Request (Invalid Name Pattern):** | |
`POST /stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "My Store!" } | |
``` | |
* **Example HTTP Response (400 Bad Request):** | |
```json | |
{ | |
// ... standard problem details ... | |
"errors": { | |
"Name": [ "Store name can only contain alphanumeric characters, underscores, and hyphens." ] | |
} | |
} | |
``` | |
### Step 16: Ensure `CreatedAt` and `UpdatedAt` are set by Service | |
* **Goal:** Verify service sets timestamps. | |
* **Acceptance Criteria Check (in test):** After a successful POST, examine the `created_at` and `updated_at` fields in the JSON response. They should be recent and typically equal on creation. | |
* **Example "Payload" (Part of the 201 Response shown in Step 6):** | |
`"created_at": "2023-10-27T10:00:00.000Z", "updated_at": "2023-10-27T10:00:00.000Z"` | |
### Step 17: Idempotency Considerations | |
* **Goal:** Document non-idempotent nature. | |
* **Example Test Scenario "Payloads":** | |
1. **Request 1:** | |
`POST /stores` with `{"name": "idempotency-test-store"}` | |
**Response 1:** `201 Created` with ID `store-A` | |
2. **Request 2 (Identical):** | |
`POST /stores` with `{"name": "idempotency-test-store"}` | |
**Response 2 (if name unique constraint):** `409 Conflict` (message: "Store with name 'idempotency-test-store' already exists.") | |
**Response 2 (if names not unique):** `201 Created` with a *new* ID `store-B` | |
### Step 18: Logging for Store Creation | |
* **Goal:** Add logging. | |
* **Example Log Output (Conceptual):** | |
* Success: `INFO: Store created successfully. StoreID: '01HMOCKIDABCDEFGH', Name: 'My New Awesome Store'.` | |
* Validation Failure: `WARN: Store creation failed validation for Name: 'My Store!'. Errors: Store name can only contain...` | |
* Service Error: `ERROR: Failed to create store 'ErrorProne Store' due to TransactionConflictException.` | |
### Step 19: Test with Max `Name` Length | |
* **Goal:** Test name length boundaries. | |
* **Example HTTP Request (Name at max length, e.g., 64 chars):** | |
`POST /stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" } // 64 chars | |
``` | |
**Expected Response:** `201 Created` | |
* **Example HTTP Request (Name exceeding max length):** | |
`POST /stores` | |
`Content-Type: application/json` | |
```json | |
{ "name": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_--toolong" } // >64 chars | |
``` | |
**Expected Response:** `400 Bad Request` (due to `StringLength` validation). | |
### Step 20: Add XML Documentation Comments | |
* **Goal:** Add /// XML comments. | |
* **Example "Payload" (C# Code Snippet):** | |
```csharp | |
/// <summary> | |
/// Create a store. | |
/// </summary> | |
/// <remarks> | |
/// Create a unique OpenFGA store which will be used to store authorization models and relationship tuples. | |
/// </remarks> | |
/// <param name="body">The request body containing the details of the store to create.</param> | |
/// <response code="201">A successful response, returning the details of the created store.</response> | |
/// <response code="400">Request failed due to invalid input (e.g., missing name, invalid name format).</response> | |
// ... other response codes documented ... | |
[HttpPost("/stores")] | |
// ... attributes ... | |
public async Task<IActionResult> CreateStore([FromBody] CreateStoreRequest body) {/*...*/} | |
``` | |
--- | |
## Endpoint: GET /stores/{store_id} (GetStore) | |
Many error response payloads (`ValidationErrorMessageResponse`, `UnauthenticatedResponse`, etc.) will be structurally identical to those in `GET /stores`. The main differences will be in the happy path and the `store_id` specific validation. | |
### Step 1: Add GetStore Action Method to StoresController | |
* **Goal:** Define the `GetStore` action method. | |
* **Example "Payload" (C# Code Snippet - in `StoresController.cs`):** | |
```csharp | |
[HttpGet("/stores/{storeId}")] // Note: parameter name matches method argument | |
[Produces("application/json")] | |
public async Task<IActionResult> GetStore([FromRoute] string storeId) | |
{ | |
// Dummy implementation for now | |
await Task.CompletedTask; | |
if (storeId == "existing-id") | |
{ | |
return Ok(new GetStoreResponse { Id = storeId, Name = "Found Store", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); | |
} | |
return NotFound(new PathUnknownErrorMessageResponse { Code = NotFoundErrorCode.StoreIdNotFound, Message = "Store not found." }); | |
} | |
``` | |
### Step 2: Define `GetStoreResponse` DTO | |
* **Goal:** Create DTO for success (structurally same as `Store` DTO). | |
* **Example "Payload" (C# Code Snippet - `GetStoreResponse.cs`):** | |
```csharp | |
// Models/GetStoreResponse.cs | |
// This is identical to the Store DTO as per the spec. | |
// You can either reuse the Store DTO or define GetStoreResponse explicitly. | |
// For explicitness: | |
using System; | |
using System.Text.Json.Serialization; | |
public class GetStoreResponse | |
{ | |
[JsonPropertyName("id")] | |
public string Id { get; set; } | |
[JsonPropertyName("name")] | |
public string Name { get; set; } | |
[JsonPropertyName("created_at")] | |
public DateTime CreatedAt { get; set; } | |
[JsonPropertyName("updated_at")] | |
public DateTime UpdatedAt { get; set; } | |
[JsonPropertyName("deleted_at")] | |
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | |
public DateTime? DeletedAt { get; set; } | |
} | |
``` | |
### Step 3: Extend `IStoreService` Interface | |
* **Goal:** Add method for retrieving a store. | |
* **Example "Payload" (C# Code Snippet - in `IStoreService.cs`):** | |
```csharp | |
Task<GetStoreResponse?> GetStoreAsync(string storeId); // Nullable if not found | |
``` | |
### Step 4: Implement Mock `StoreService.GetStoreAsync` | |
* **Goal:** Add mock implementation. | |
* **Example "Payload" (C# Code Snippet - in `MockStoreService.cs`):** | |
```csharp | |
public Task<GetStoreResponse?> GetStoreAsync(string storeId) | |
{ | |
var store = MockData.FirstOrDefault(s => s.Id == storeId); | |
if (store == null) | |
{ | |
return Task.FromResult<GetStoreResponse?>(null); | |
} | |
// Map Store to GetStoreResponse (they are identical here) | |
var response = new GetStoreResponse | |
{ | |
Id = store.Id, | |
Name = store.Name, | |
CreatedAt = store.CreatedAt, | |
UpdatedAt = store.UpdatedAt, | |
DeletedAt = store.DeletedAt | |
}; | |
return Task.FromResult<GetStoreResponse?>(response); | |
} | |
``` | |
### Step 5: Implement Controller Logic for Happy Path (200 OK) | |
* **Goal:** Call service, return 200 OK if found. | |
* **Controller Code in `GetStore`:** | |
```csharp | |
public async Task<IActionResult> GetStore([FromRoute] string storeId) | |
{ | |
// Basic validation (more in step 7) | |
if (string.IsNullOrWhiteSpace(storeId)) | |
{ | |
return BadRequest(new ValidationErrorMessageResponse { Code = ErrorCode.StoreIdInvalidLength, Message = "Store ID cannot be empty." }); | |
} | |
var storeResponse = await _storeService.GetStoreAsync(storeId); | |
if (storeResponse == null) | |
{ | |
return NotFound(new PathUnknownErrorMessageResponse { Code = NotFoundErrorCode.StoreIdNotFound, Message = $"Store with ID '{storeId}' not found." }); | |
} | |
return Ok(storeResponse); | |
} | |
``` | |
* **Example HTTP Request:** | |
`GET /stores/01GKNSYMQ4KX71S0N0Z250BZ5X` (an existing ID from mock data) | |
`Accept: application/json` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"id": "01GKNSYMQ4KX71S0N0Z250BZ5X", | |
"name": "FGA Test Store", | |
"created_at": "2023-10-22T12:30:45.123Z", | |
"updated_at": "2023-10-26T15:00:00.000Z" | |
// "deleted_at" would be present if set | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(GetStoreResponse), StatusCodes.Status200OK)]` | |
### Step 6: Handle Store Not Found (404 PathUnknownErrorMessageResponse) | |
* **Goal:** If service indicates not found, return 404. | |
* **Controller Code:** (Shown in Step 5 `if (storeResponse == null)`) | |
* **Example HTTP Request:** | |
`GET /stores/non-existent-id-123` | |
`Accept: application/json` | |
* **Example HTTP Response (404 Not Found):** | |
```json | |
{ | |
"code": "store_id_not_found", // From NotFoundErrorCode enum | |
"message": "Store with ID 'non-existent-id-123' not found." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(PathUnknownErrorMessageResponse), StatusCodes.Status404NotFound)]` | |
### Step 7: Implement Input Validation for `store_id` | |
* **Goal:** Validate `store_id` format/length. | |
* **Controller Code (Conceptual validation):** | |
```csharp | |
// In GetStore, before service call: | |
// const int MaxStoreIdLength = 50; // Example | |
// if (storeId.Length > MaxStoreIdLength) | |
// { | |
// return BadRequest(new ValidationErrorMessageResponse { Code = ErrorCode.StoreIdInvalidLength, Message = $"Store ID exceeds maximum length of {MaxStoreIdLength} characters." }); | |
// } | |
// if (!IsValidUlidFormat(storeId)) // If IDs are ULIDs | |
// { | |
// return BadRequest(new ValidationErrorMessageResponse { Code = ErrorCode.ValidationError, Message = "Invalid Store ID format." }); | |
// } | |
``` | |
* **Example HTTP Request (Invalid ID - too long or bad format):** | |
`GET /stores/this-id-is-way-too-long-for-our-system-and-should-fail-validation-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | |
* **Example HTTP Response (400 Bad Request):** | |
```json | |
{ | |
"code": "store_id_invalid_length", // Or general validation_error | |
"message": "Store ID exceeds maximum length of 50 characters." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(ValidationErrorMessageResponse), StatusCodes.Status400BadRequest)]` | |
### Step 8 to 12: Error Responses (401, 403, 409, 422, 500) | |
* **Payloads for these error responses will be structurally identical to those in `GET /stores` (Steps 14, 15, 17, 18, 19 respectively).** The trigger would be the same (auth failure, permission denial, service throwing specific exceptions during `_storeService.GetStoreAsync(storeId)`). | |
* Ensure corresponding `ProducesResponseType` attributes are added. | |
### Step 13: Test `store_id` Path Parameter Binding | |
* **No direct "payload" other than the URL itself.** | |
* **Test:** Make a request like `GET /stores/my-actual-id-value`. Debug or log within the `GetStore` action method to confirm the `storeId` parameter received is indeed `"my-actual-id-value"`. | |
### Step 14: Verify `DeletedAt` Field Handling | |
* **Goal:** Ensure `DeletedAt` is correct. | |
* **Setup:** Mock a store that is soft-deleted: | |
```csharp | |
// In MockStoreService MockData: | |
// new Store { Id = "deleted-store-01", Name = "Old Deleted Store", ..., DeletedAt = DateTime.UtcNow.AddDays(-20) } | |
``` | |
* **Example HTTP Request:** | |
`GET /stores/deleted-store-01` | |
* **Example HTTP Response (200 OK):** | |
```json | |
{ | |
"id": "deleted-store-01", | |
"name": "Old Deleted Store", | |
"created_at": "...", | |
"updated_at": "...", | |
"deleted_at": "2023-10-07T10:00:00.000Z" // Example past date | |
} | |
``` | |
* **Example HTTP Request (Active Store):** | |
`GET /stores/01GKNSYMQ4KX71S0N0Z250BZ5X` | |
* **Example HTTP Response (200 OK):** (deleted_at should be absent if null) | |
```json | |
{ | |
"id": "01GKNSYMQ4KX71S0N0Z250BZ5X", | |
"name": "FGA Test Store", | |
// ... other fields ... | |
// "deleted_at": null // This field would be omitted by JsonIgnoreCondition.WhenWritingNull | |
} | |
``` | |
### Steps 15-19: Advanced Tests (Case Sensitivity, Special Chars, Logging, Empty ID, Concurrency) | |
* These steps involve specific test scenarios rather than distinct request/response "payloads" beyond what's already covered. For logging, the payload is the log message format. For concurrency, it's more about test setup and observed behavior. | |
### Step 20: Add XML Documentation Comments | |
* **Goal:** Add /// XML comments. | |
* **Example "Payload" (C# Code Snippet):** | |
```csharp | |
/// <summary> | |
/// Get a store. | |
/// </summary> | |
/// <remarks> | |
/// Returns an OpenFGA store by its identifier. | |
/// </remarks> | |
/// <param name="storeId">The identifier of the store to retrieve.</param> | |
/// <response code="200">A successful response, returning the details of the store.</response> | |
/// <response code="400">Request failed due to invalid input (e.g., invalid store_id format).</response> | |
/// <response code="404">Store not found.</response> | |
// ... other response codes documented ... | |
[HttpGet("/stores/{storeId}")] | |
// ... attributes ... | |
public async Task<IActionResult> GetStore([FromRoute] string storeId) {/*...*/} | |
``` | |
--- | |
## Endpoint: DELETE /stores/{store_id} (DeleteStore) | |
### Step 1: Add DeleteStore Action Method to StoresController | |
* **Goal:** Define `DeleteStore` action. | |
* **Example "Payload" (C# Code Snippet - in `StoresController.cs`):** | |
```csharp | |
[HttpDelete("/stores/{storeId}")] | |
public async Task<IActionResult> DeleteStore([FromRoute] string storeId) | |
{ | |
// Dummy implementation for now | |
await Task.CompletedTask; | |
if (storeId == "existing-id-to-delete") | |
{ | |
return NoContent(); // 204 | |
} | |
return NotFound(new PathUnknownErrorMessageResponse { Code = NotFoundErrorCode.StoreIdNotFound, Message = "Store not found for deletion." }); | |
} | |
``` | |
### Step 2: Extend `IStoreService` Interface for Delete | |
* **Goal:** Add delete method to service. | |
* **Example "Payload" (C# Code Snippet - in `IStoreService.cs`):** | |
```csharp | |
// Returns true if found and deleted, false if not found. | |
// Could also be Task and throw a NotFoundException. | |
Task<bool> DeleteStoreAsync(string storeId); | |
``` | |
### Step 3: Implement Mock `StoreService.DeleteStoreAsync` | |
* **Goal:** Add mock delete implementation. | |
* **Example "Payload" (C# Code Snippet - in `MockStoreService.cs`):** | |
```csharp | |
public Task<bool> DeleteStoreAsync(string storeId) | |
{ | |
var store = MockData.FirstOrDefault(s => s.Id == storeId); | |
if (store == null) | |
{ | |
return Task.FromResult(false); // Not found | |
} | |
// Simulate soft delete or removal | |
// If soft delete: store.DeletedAt = DateTime.UtcNow; | |
MockData.Remove(store); // For hard delete in mock | |
return Task.FromResult(true); // Found and "deleted" | |
} | |
``` | |
### Step 4: Implement Controller Logic for Happy Path (204 No Content) | |
* **Goal:** Call service, return 204 No Content. | |
* **Controller Code in `DeleteStore`:** | |
```csharp | |
public async Task<IActionResult> DeleteStore([FromRoute] string storeId) | |
{ | |
// Basic validation | |
if (string.IsNullOrWhiteSpace(storeId)) | |
{ | |
return BadRequest(new ValidationErrorMessageResponse { Code = ErrorCode.StoreIdInvalidLength, Message = "Store ID cannot be empty." }); | |
} | |
bool deleted = await _storeService.DeleteStoreAsync(storeId); | |
if (!deleted) | |
{ | |
// If service returns false for not found, map to 404. | |
// If service throws NotFoundException, a try-catch would handle it. | |
return NotFound(new PathUnknownErrorMessageResponse { Code = NotFoundErrorCode.StoreIdNotFound, Message = $"Store with ID '{storeId}' not found for deletion." }); | |
} | |
return NoContent(); // 204 | |
} | |
``` | |
* **Example HTTP Request:** | |
`DELETE /stores/01GKNSYMQ4KX71S0N0Z250BZ5X` (an existing ID) | |
* **Example HTTP Response (204 No Content):** | |
`Status: 204 No Content` | |
(No response body) | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(StatusCodes.Status204NoContent)]` | |
### Step 5: Handle Store Not Found During Delete (404) | |
* **Goal:** If store not found, return 404. | |
* **Controller Code:** (Shown in Step 4 `if (!deleted)`) | |
* **Example HTTP Request:** | |
`DELETE /stores/non-existent-id-for-delete` | |
* **Example HTTP Response (404 Not Found):** | |
```json | |
{ | |
"code": "store_id_not_found", | |
"message": "Store with ID 'non-existent-id-for-delete' not found for deletion." | |
} | |
``` | |
* **ProducesResponseType attribute:** | |
`[ProducesResponseType(typeof(PathUnknownErrorMessageResponse), StatusCodes.Status404NotFound)]` | |
### Step 6 to 11: Validation and Error Responses (400, 401, 403, 409, 422, 500) | |
* **Validation for `store_id` (Step 6):** Similar to `GET /stores/{store_id}` Step 7. Payload would be an invalid `store_id` in the URL, e.g., `DELETE /stores/!@#$`, resulting in a 400 with `ValidationErrorMessageResponse`. | |
* **Error Payloads (Steps 7-11):** Structurally identical to those in `GET /stores` and `GET /stores/{store_id}` for the respective error codes. Triggers are auth failure, permission denial, or service throwing specific exceptions during `_storeService.DeleteStoreAsync(storeId)`. | |
* Ensure corresponding `ProducesResponseType` attributes are added for each. | |
### Step 12: Verify Idempotency of Delete | |
* **Goal:** Test idempotency. | |
* **Example Test Scenario "Payloads":** | |
1. **Request 1 (Delete existing):** | |
`DELETE /stores/01GKNT00ABCD1S0N0Z250CDEFG` | |
**Response 1:** `204 No Content` | |
2. **Request 2 (Delete same, now non-existent):** | |
`DELETE /stores/01GKNT00ABCD1S0N0Z250CDEFG` | |
**Response 2:** `404 Not Found` (with `PathUnknownErrorMessageResponse` body) | |
3. **Request 3 (Delete initially non-existent):** | |
`DELETE /stores/id-that-never-existed` | |
**Response 3:** `404 Not Found` | |
### Step 13: Clarify "does not delete the data associated" | |
* **This is a service-layer concern.** The "payload" here is the behavior of `_storeService.DeleteStoreAsync`. It should only mark the store as deleted or remove its primary record, not cascade delete related authorization models or tuples. | |
* **Test (Conceptual):** | |
1. Create store, add auth model, add tuples. | |
2. DELETE the store. | |
3. Attempt to read the auth model (e.g., via an endpoint if it exists for a "deleted" store's models, or directly query DB). The data should still be there. | |
### Step 14-19: Advanced Tests (Logging, Case Sensitivity, Dependencies, Long IDs, Response Body on 204, Empty ID) | |
* These steps involve specific test scenarios. | |
* **Logging:** Example log: `INFO: Store '01GKNSYMQ4KX71S0N0Z250BZ5X' deleted successfully.` or `WARN: Attempt to delete non-existent store 'blah'.` | |
* **Response Body on 204 (Step 18):** Verify `Content-Length: 0` or no body in HTTP trace. | |
### Step 20: Add XML Documentation Comments | |
* **Goal:** Add /// XML comments. | |
* **Example "Payload" (C# Code Snippet):** | |
```csharp | |
/// <summary> | |
/// Delete a store. | |
/// </summary> | |
/// <remarks> | |
/// Delete an OpenFGA store. This does not delete the data associated with the store, like tuples or authorization models. | |
/// </remarks> | |
/// <param name="storeId">The identifier of the store to delete.</param> | |
/// <response code="204">A successful response, indicating the store was deleted (or was already not present).</response> | |
/// <response code="400">Request failed due to invalid input (e.g., invalid store_id format).</response> | |
/// <response code="404">Store not found.</response> | |
// ... other response codes documented ... | |
[HttpDelete("/stores/{storeId}")] | |
// ... attributes ... | |
public async Task<IActionResult> DeleteStore([FromRoute] string storeId) {/*...*/} | |
``` | |
This level of detail for all 17 endpoints would be exceptionally long. The pattern, however, should be clear. For subsequent endpoints, I can summarize the types of payloads for brevity unless you need a specific one expanded. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment