Documentation

reference/api/api-documentation.md

API Documentation Guide for Dynaplex Architecture

This guide covers API design, documentation, and client generation standards for the Acsis Core Dynaplex architecture.

๐ŸŽฏ API Design Principles

The Dynaplex architecture follows these API design principles:

  • RESTful Design: Follow REST conventions for resource-based APIs
  • Contract-First: Define OpenAPI specifications before implementation
  • Type Safety: Generate strongly-typed clients from OpenAPI specs
  • Versioning: Support multiple API versions gracefully
  • Consistency: Maintain consistent patterns across all services

๐Ÿ“‹ OpenAPI Specification

OpenAPI Structure

Each service must maintain its OpenAPI specification:

components/your-component/
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ Acsis.Components.YourComponent/
โ”‚       โ”œโ”€โ”€ openapi.yaml           # OpenAPI 3.0+ specification
โ”‚       โ””โ”€โ”€ Controllers/            # Implementation matching spec

OpenAPI Template

openapi: 3.0.3
info:
  title: Asset Service API
  description: Dynaplex Asset Management Service
  version: 1.0.0
  contact:
    name: API Support
    email: api-support@acsis.com
servers:
  - url: https://localhost:40443
    description: Local development
  - url: https://api.acsis.com
    description: Production
tags:
  - name: Assets
    description: Asset management operations
  - name: Health
    description: Service health endpoints

paths:
  /api/v1/assets:
    get:
      tags:
        - Assets
      summary: Get all assets
      operationId: getAssets
      parameters:
        - $ref: '#/components/parameters/PageNumber'
        - $ref: '#/components/parameters/PageSize'
        - $ref: '#/components/parameters/SortBy'
        - $ref: '#/components/parameters/FilterBy'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
    
    post:
      tags:
        - Assets
      summary: Create a new asset
      operationId: createAsset
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAssetRequest'
      responses:
        '201':
          description: Asset created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
          headers:
            Location:
              description: URL of the created asset
              schema:
                type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

  /api/v1/assets/{assetId}:
    get:
      tags:
        - Assets
      summary: Get asset by ID
      operationId: getAssetById
      parameters:
        - name: assetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  schemas:
    Asset:
      type: object
      required:
        - id
        - name
        - type
        - status
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Asset name
        type:
          type: string
          enum: [Equipment, Vehicle, Tool, Facility]
          description: Asset type
        status:
          type: string
          enum: [Active, Inactive, Maintenance, Retired]
          description: Current status
        location:
          $ref: '#/components/schemas/Location'
        attributes:
          type: object
          additionalProperties: true
          description: Custom attributes
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateAssetRequest:
      type: object
      required:
        - name
        - type
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        type:
          type: string
          enum: [Equipment, Vehicle, Tool, Facility]
        locationId:
          type: string
          format: uuid
        attributes:
          type: object
          additionalProperties: true

    AssetListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Asset'
        pagination:
          $ref: '#/components/schemas/PaginationInfo'

    PaginationInfo:
      type: object
      properties:
        currentPage:
          type: integer
          minimum: 1
        pageSize:
          type: integer
          minimum: 1
          maximum: 100
        totalItems:
          type: integer
        totalPages:
          type: integer

    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
        message:
          type: string
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string

    Location:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        path:
          type: string
          description: Hierarchical path

  parameters:
    PageNumber:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    PageSize:
      name: pageSize
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    SortBy:
      name: sortBy
      in: query
      schema:
        type: string
        enum: [name, createdAt, updatedAt]
        default: createdAt
    FilterBy:
      name: filter
      in: query
      schema:
        type: string
        description: Filter expression

  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: Resource conflict
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key

security:
  - bearerAuth: []
  - apiKey: []

๐Ÿ“– Scalar API Documentation

Configuring Scalar

Each Dynaplex service includes Scalar for interactive API documentation:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers();
builder.Services.AddOpenApi();

var app = builder.Build();

// Configure Scalar
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference(options =>
    {
        options.Title = "Asset Service API";
        options.Theme = ScalarTheme.Modern;
        options.DarkMode = true;
        options.ShowSidebar = true;
        options.DefaultOpenAllTags = true;
        options.CustomCss = @"
            .scalar-card { border-radius: 8px; }
            .scalar-button { border-radius: 4px; }
        ";
    });
}

Accessing Scalar Documentation

Each service exposes Scalar at /scalar:

  • CoreData: https://localhost:40443/scalar
  • Catalog: https://localhost:43443/scalar
  • Identity: https://localhost:41443/scalar

Scalar Features

  • Interactive Testing: Execute API calls directly from the documentation
  • Authentication: Configure auth tokens for testing protected endpoints
  • Request Builder: Construct complex requests with headers and parameters
  • Response Viewer: Inspect response headers, body, and status codes
  • Code Generation: Generate client code in multiple languages

๐Ÿ”ง Microsoft Kiota Client Generation

Installing Kiota

# Install Kiota CLI
dotnet tool install -g Microsoft.OpenApi.Kiota

# Verify installation
kiota --version

Generating API Clients

Manual Generation

# Generate C# client from OpenAPI spec
kiota generate \
  --language csharp \
  --openapi ./openapi.yaml \
  --output ./ApiClient \
  --namespace Acsis.Components.Asset.ApiClient \
  --class-name AssetApiClient

Automated Generation in Build

Add to .csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.0.0" />
    <PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.0.0" />
    <PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.0.0" />
  </ItemGroup>

  <Target Name="GenerateApiClient" BeforeTargets="BeforeBuild">
    <Exec Command="kiota generate --language csharp --openapi ../Acsis.Components.Asset/openapi.yaml --output ./Generated --namespace $(RootNamespace)" />
  </Target>
</Project>

Using Generated Clients

// Configure DI
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAssetApiClient(
        this IServiceCollection services,
        string baseUrl)
    {
        services.AddHttpClient<AssetApiClient>(client =>
        {
            client.BaseAddress = new Uri(baseUrl);
        });

        services.AddTransient<IAuthenticationProvider, BearerTokenAuthenticationProvider>();
        services.AddTransient<IRequestAdapter, HttpClientRequestAdapter>();
        
        services.AddTransient(sp =>
        {
            var httpClient = sp.GetRequiredService<HttpClient>();
            var authProvider = sp.GetRequiredService<IAuthenticationProvider>();
            var requestAdapter = new HttpClientRequestAdapter(authProvider, httpClient);
            return new AssetApiClient(requestAdapter);
        });

        return services;
    }
}

// Usage in service
public class CatalogService
{
    private readonly AssetApiClient _assetClient;

    public CatalogService(AssetApiClient assetClient)
    {
        _assetClient = assetClient;
    }

    public async Task<Asset> GetAssetAsync(Guid id)
    {
        return await _assetClient.Api.V1.Assets[id.ToString()].GetAsync();
    }

    public async Task<AssetListResponse> GetAssetsAsync(int page = 1, int pageSize = 20)
    {
        return await _assetClient.Api.V1.Assets.GetAsync(config =>
        {
            config.QueryParameters.Page = page;
            config.QueryParameters.PageSize = pageSize;
        });
    }

    public async Task<Asset> CreateAssetAsync(CreateAssetRequest request)
    {
        return await _assetClient.Api.V1.Assets.PostAsync(request);
    }
}

๐Ÿ”„ API Versioning

Versioning Strategy

// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-API-Version"),
        new MediaTypeApiVersionReader("version")
    );
});

builder.Services.AddVersionedApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

Versioned Controllers

[ApiController]
[Route("api/v{version:apiVersion}/assets")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class AssetController : ControllerBase
{
    [HttpGet]
    [MapToApiVersion("1.0")]
    public async Task<ActionResult<IEnumerable<AssetV1Dto>>> GetV1()
    {
        // V1 implementation
    }

    [HttpGet]
    [MapToApiVersion("2.0")]
    public async Task<ActionResult<IEnumerable<AssetV2Dto>>> GetV2()
    {
        // V2 implementation with additional fields
    }
}

๐Ÿ“ API Design Standards

Resource Naming

# Good - Plural nouns for collections
GET /api/v1/assets
GET /api/v1/locations
GET /api/v1/users

# Good - Nested resources
GET /api/v1/assets/{id}/attributes
GET /api/v1/locations/{id}/children

# Bad - Verbs in URLs
GET /api/v1/getAssets
POST /api/v1/createAsset

HTTP Methods

Method Usage Idempotent Safe
GET Retrieve resource(s) Yes Yes
POST Create new resource No No
PUT Replace entire resource Yes No
PATCH Partial update No No
DELETE Remove resource Yes No

Status Codes

// Success codes
200 OK              // Successful GET, PUT, PATCH
201 Created         // Successful POST
204 No Content      // Successful DELETE
202 Accepted        // Async operation started

// Client error codes
400 Bad Request     // Invalid request data
401 Unauthorized    // Missing/invalid auth
403 Forbidden       // Authorized but not allowed
404 Not Found       // Resource doesn't exist
409 Conflict        // Resource state conflict
422 Unprocessable   // Validation errors

// Server error codes
500 Internal Error  // Unexpected server error
502 Bad Gateway     // Upstream service error
503 Unavailable     // Service temporarily down
504 Gateway Timeout // Upstream timeout

Request/Response Format

Standard Response Envelope

{
  "data": { /* Resource data */ },
  "metadata": {
    "timestamp": "2024-01-01T00:00:00Z",
    "version": "1.0",
    "requestId": "abc-123"
  },
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "totalItems": 100,
    "totalPages": 5
  }
}

Error Response Format

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "timestamp": "2024-01-01T00:00:00Z",
    "requestId": "abc-123",
    "details": [
      {
        "field": "name",
        "message": "Name is required"
      },
      {
        "field": "email",
        "message": "Invalid email format"
      }
    ]
  }
}

Pagination

public class PaginatedRequest
{
    [FromQuery] public int Page { get; set; } = 1;
    [FromQuery] public int PageSize { get; set; } = 20;
    [FromQuery] public string SortBy { get; set; } = "createdAt";
    [FromQuery] public SortDirection SortDirection { get; set; } = SortDirection.Desc;
}

public class PaginatedResponse<T>
{
    public IEnumerable<T> Items { get; set; }
    public int CurrentPage { get; set; }
    public int PageSize { get; set; }
    public int TotalItems { get; set; }
    public int TotalPages { get; set; }
    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;
}

Filtering and Searching

# Simple filtering
GET /api/v1/assets?status=active&type=equipment

# Range queries
GET /api/v1/assets?createdAfter=2024-01-01&createdBefore=2024-12-31

# Search
GET /api/v1/assets?q=pump&fields=name,description

# Complex filtering (OData-style)
GET /api/v1/assets?$filter=status eq 'active' and price gt 1000

๐Ÿ”’ API Security

Authentication

// JWT Bearer configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = configuration["Auth:Authority"];
        options.Audience = configuration["Auth:Audience"];
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

// API Key authentication
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-API-Key", out var apiKey))
        {
            return AuthenticateResult.Fail("Missing API Key");
        }

        // Validate API key
        var isValid = await ValidateApiKeyAsync(apiKey);
        if (!isValid)
        {
            return AuthenticateResult.Fail("Invalid API Key");
        }

        var claims = new[] { new Claim(ClaimTypes.Name, "ApiKeyUser") };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}

Rate Limiting

// Configure rate limiting
builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
        httpContext => RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: httpContext.User?.Identity?.Name ?? httpContext.Request.Headers.Host.ToString(),
            factory: partition => new FixedWindowRateLimiterOptions
            {
                AutoReplenishment = true,
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            }));
});

// Apply to controllers
[ApiController]
[EnableRateLimiting("api")]
public class AssetController : ControllerBase
{
    // Endpoints are rate limited
}

๐Ÿ“Š API Monitoring

Health Checks

builder.Services.AddHealthChecks()
    .AddSqlServer(configuration.GetConnectionString("Default"))
    .AddUrlGroup(new Uri("https://localhost:40443/health"), "CoreData Service")
    .AddCheck<CustomHealthCheck>("custom");

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Logging

[ApiController]
public class AssetController : ControllerBase
{
    private readonly ILogger<AssetController> _logger;

    [HttpGet("{id}")]
    public async Task<ActionResult<Asset>> Get(Guid id)
    {
        using (_logger.BeginScope(new { AssetId = id }))
        {
            _logger.LogInformation("Getting asset {AssetId}", id);
            
            try
            {
                var asset = await _service.GetAsync(id);
                return Ok(asset);
            }
            catch (NotFoundException ex)
            {
                _logger.LogWarning(ex, "Asset {AssetId} not found", id);
                return NotFound();
            }
        }
    }
}

๐Ÿงช API Testing

Integration Testing

public class AssetApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public AssetApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetAssets_ReturnsSuccessAndCorrectContentType()
    {
        // Act
        var response = await _client.GetAsync("/api/v1/assets");

        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType);
    }

    [Fact]
    public async Task CreateAsset_ReturnsCreatedWithLocation()
    {
        // Arrange
        var request = new { Name = "Test Asset", Type = "Equipment" };

        // Act
        var response = await _client.PostAsJsonAsync("/api/v1/assets", request);

        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);
    }
}

๐Ÿ“š Resources

๐Ÿš€ Quick Reference

Generate API Client

kiota generate --language csharp --openapi ./openapi.yaml --output ./ApiClient --namespace Acsis.Components.Asset.ApiClient

Update OpenAPI Spec

# Export from running service
curl https://localhost:40443/openapi/v1.json > openapi.yaml

# Validate spec
openapi-validator openapi.yaml

Test API Endpoint

# Using curl
curl -X GET https://localhost:40443/api/v1/assets \
  -H "Authorization: Bearer $TOKEN"

# Using httpie
http GET localhost:40443/api/v1/assets Authorization:"Bearer $TOKEN"