Documentation

fsds/dplx-yarp-api-gateway-implementation-plan.md

YARP API Gateway Implementation Plan

Status: Pending Implementation

Created: 2025-10-18
Blocked By: Base component restructuring work in progress
Priority: High (Developer Experience)


Problem Statement

Currently, developers face friction when working with Aspire-orchestrated microservices:

  1. Dynamic Port Assignment: Aspire assigns different ports on each launch
  2. Manual Lookup Required: Developers must check the Aspire dashboard to find current ports
  3. HTTP Client Updates: http-client.env.json files need constant updating with new ports
  4. Poor Developer Experience: Breaks flow, wastes time, discourages API testing

Goal: Enable developers to use clean, memorable URLs like http://localhost:8080/identity/auth/login without ever checking ports.


Solution Architecture

High-Level Design

┌─────────────────────────────────────────────────────────────┐
│                    Developer / Browser                       │
│            http://localhost:8080/identity/auth/login        │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                   YARP API Gateway                           │
│                  (Aspire Managed Service)                    │
│   - Binds to fixed port: 8080                               │
│   - Uses Aspire Service Discovery                           │
│   - Routes: /service-name/* → actual service                │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ├──────────────┬─────────────┬──────────────┐
                           ▼              ▼             ▼              ▼
                    ┌──────────┐   ┌──────────┐  ┌──────────┐  ┌──────────┐
                    │ Identity │   │CoreData  │  │ Spatial  │  │  Events  │
                    │:dynamic  │   │:dynamic  │  │:dynamic  │  │:dynamic  │
                    └──────────┘   └──────────┘  └──────────┘  └──────────┘

URL Scheme

Gateway URLs (used by developers/browsers):

  • http://localhost:8080/identity/*
  • http://localhost:8080/core-data/*
  • http://localhost:8080/spatial/*
  • http://localhost:8080/system-environment/*
  • etc.

Service-to-Service (unchanged - uses Aspire Service Discovery):

// Services still talk directly via service discovery
await httpClient.GetAsync("https+http://identity/auth/me");

Why YARP?

  1. Built by Microsoft - First-class .NET support, maintained actively
  2. Aspire Integration - Works seamlessly with Aspire Service Discovery
  3. Production Ready - Same gateway pattern can deploy to cloud
  4. Auto-Discovery - No manual route configuration when adding services
  5. Flexible - Easy to add CORS, auth, rate limiting, etc.

Implementation Steps

Step 1: Create Gateway Project

Location: /engines/gateway/src/Acsis.Dynaplex.Engines.Gateway/

Structure:

engines/gateway/
├── src/
│   └── Acsis.Dynaplex.Engines.Gateway/
│       ├── Acsis.Dynaplex.Engines.Gateway.csproj
│       ├── Program.cs
│       ├── appsettings.json
│       └── Services/
│           └── RouteConfigProvider.cs

Dependencies:

<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.0.0" />

Step 2: Implement Gateway Service

Program.cs:

using Acsis.Dynaplex.Engines.Gateway.Services;

var builder = WebApplication.CreateBuilder(args);

// Add Aspire Service Defaults (OpenTelemetry, health checks, etc.)
builder.AddServiceDefaults();

// Add Service Discovery
builder.Services.AddServiceDiscovery();

// Add YARP with custom route provider
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddServiceDiscoveryDestinationResolver();

// Add route configuration provider
builder.Services.AddSingleton<RouteConfigProvider>();

var app = builder.Build();

app.MapReverseProxy();

app.Run();

appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Yarp": "Information"
    }
  },
  "ReverseProxy": {
    "Routes": {
      "identity-route": {
        "ClusterId": "identity-cluster",
        "Match": {
          "Path": "/identity/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/identity" }
        ]
      },
      "core-data-route": {
        "ClusterId": "core-data-cluster",
        "Match": {
          "Path": "/core-data/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/core-data" }
        ]
      },
      "spatial-route": {
        "ClusterId": "spatial-cluster",
        "Match": {
          "Path": "/spatial/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/spatial" }
        ]
      },
      "system-environment-route": {
        "ClusterId": "system-environment-cluster",
        "Match": {
          "Path": "/system-environment/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/system-environment" }
        ]
      },
      "events-route": {
        "ClusterId": "events-cluster",
        "Match": {
          "Path": "/events/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/events" }
        ]
      },
      "catalog-route": {
        "ClusterId": "catalog-cluster",
        "Match": {
          "Path": "/catalog/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/catalog" }
        ]
      },
      "edge-route": {
        "ClusterId": "edge-cluster",
        "Match": {
          "Path": "/edge/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/edge" }
        ]
      },
      "bbu-route": {
        "ClusterId": "bbu-cluster",
        "Match": {
          "Path": "/bbu/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/bbu" }
        ]
      },
      "doc-web-route": {
        "ClusterId": "doc-web-cluster",
        "Match": {
          "Path": "/docs/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/docs" }
        ]
      },
      "importer-route": {
        "ClusterId": "importer-cluster",
        "Match": {
          "Path": "/importer/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/importer" }
        ]
      }
    },
    "Clusters": {
      "identity-cluster": {
        "Destinations": {
          "identity": {
            "Address": "https+http://identity"
          }
        }
      },
      "core-data-cluster": {
        "Destinations": {
          "core-data": {
            "Address": "https+http://core-data"
          }
        }
      },
      "spatial-cluster": {
        "Destinations": {
          "spatial": {
            "Address": "https+http://spatial"
          }
        }
      },
      "system-environment-cluster": {
        "Destinations": {
          "system-environment": {
            "Address": "https+http://system-environment"
          }
        }
      },
      "events-cluster": {
        "Destinations": {
          "events": {
            "Address": "https+http://events"
          }
        }
      },
      "catalog-cluster": {
        "Destinations": {
          "catalog": {
            "Address": "https+http://catalog"
          }
        }
      },
      "edge-cluster": {
        "Destinations": {
          "edge": {
            "Address": "https+http://edge"
          }
        }
      },
      "bbu-cluster": {
        "Destinations": {
          "bbu": {
            "Address": "https+http://bbu"
          }
        }
      },
      "doc-web-cluster": {
        "Destinations": {
          "doc-web": {
            "Address": "https+http://doc-web"
          }
        }
      },
      "importer-cluster": {
        "Destinations": {
          "importer": {
            "Address": "https+http://importer"
          }
        }
      }
    }
  }
}

Key Concepts:

  • Routes: Map URL paths to clusters (e.g., /identity/* → identity-cluster)
  • Transforms: Remove the service prefix before forwarding (e.g., /identity/auth/login/auth/login)
  • Clusters: Groups of destinations (usually just one per service)
  • Destinations: Actual service addresses using Aspire service discovery format (https+http://service-name)

Step 3: Add Gateway to EdgeLiteBuilder

Location: /projects/edge-lite/src/Acsis.Bases.EdgeLite/EdgeLiteBuilder.cs

Add method:

public EdgeLiteBuilder WithGateway<TGateway>()
    where TGateway : IProjectMetadata, new() {

    // Gateway should start first (or at least early) and bind to fixed port
    Gateway = _builder.AddProject<TGateway>("gateway")
        .WithHttpEndpoint(port: 8080, name: "http")
        .WithAppInsightsIfAvailable(AppInsights)
        .PublishAsAzureContainerApp((infra, app) => {
            app.Name = $"ca-{GroupIdentifier}-gateway";
        });

    return this;
}

Add property:

public IResourceBuilder<ProjectResource>? Gateway { get; private set; }

Update AppHost to use it:

builder.AddEdgeLite()
    .WithAppConfiguration()
    .WithAppInsights()
    .WithContainerAppEnvironment()
    .WithDefaultDatabase()
    .WithGateway<Acsis_Components_Gateway>()  // Add gateway early
    .WithCoreData<Acsis_Components_CoreData, Acsis_Components_CoreData_DbMigrator>()
    .WithSystemEnvironment<Acsis_Components_SystemEnvironment, Acsis_Components_SystemEnvironment_DbMigrator>()
    .WithIdentity<Acsis_Components_Identity, Acsis_Components_Identity_DbMigrator>()
    // ... rest of services
    .Build();

Step 4: Update HTTP Client Environment Files

Update all http-client.env.json files to use gateway URLs:

Before:

{
  "dev": {
    "baseUrl": "http://localhost:53573",
    "comment": "Local development - Aspire managed port (update port from dashboard)"
  }
}

After:

{
  "dev": {
    "baseUrl": "http://localhost:8080/identity",
    "comment": "Routes through YARP API Gateway - no port updates needed!"
  }
}

Files to update:

  • /engines/identity/resources/jetbrains-http-client/http-client.env.json
  • /engines/system-environment/resources/jetbrains-http-client/http-client.env.json
  • Any other components with HTTP client files

Step 5: Add Health Checks

Update Gateway Program.cs:

builder.Services.AddHealthChecks()
    .AddCheck("gateway", () => HealthCheckResult.Healthy("Gateway is running"));

app.MapDefaultEndpoints(); // Adds /health and /alive endpoints

Step 6: Add Logging and Diagnostics

Update appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Yarp.ReverseProxy": "Information"
    }
  }
}

Add request logging middleware (optional but recommended):

app.Use(async (context, next) =>
{
    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogInformation("Gateway request: {Method} {Path} → {Service}",
        context.Request.Method,
        context.Request.Path,
        context.Request.Path.Value?.Split('/').ElementAtOrDefault(1) ?? "unknown");

    await next();
});

Testing Plan

Phase 1: Gateway Startup

  1. Start Aspire AppHost
  2. Verify Gateway appears in Aspire dashboard
  3. Verify Gateway binds to port 8080
  4. Check Gateway health endpoint: http://localhost:8080/health

Phase 2: Route Verification

For each service, test the gateway route:

Identity:

# Via Gateway
curl http://localhost:8080/identity/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}'

# Should return JWT token (200 OK)

SystemEnvironment:

# Via Gateway
curl http://localhost:8080/system-environment/api/health

# Should return health status (200 OK)

Repeat for all services.

Phase 3: HTTP Client Testing

  1. Open JetBrains HTTP Client
  2. Execute "Login with admin credentials" from AuthenticationApi.http
  3. Verify token is captured
  4. Execute "Get current authenticated user information"
  5. Verify user data returned

Phase 4: Service-to-Service Communication

Verify services still communicate directly (not through gateway):

Check Aspire logs to confirm direct connections, not routed through gateway.

Phase 5: Error Handling

Test failure scenarios:

  1. Service not running → Gateway should return 503 Service Unavailable
  2. Invalid route → Gateway should return 404 Not Found
  3. Service returns error → Gateway should forward error response

Technical Details

Service Discovery Integration

YARP uses Aspire's service discovery via the https+http:// scheme:

{
  "Destinations": {
    "identity": {
      "Address": "https+http://identity"
    }
  }
}

This tells YARP to:

  1. Query Aspire service discovery for "identity" service
  2. Resolve to actual endpoint (e.g., http://localhost:53573)
  3. Forward requests to that endpoint
  4. Auto-update if service moves to different port

Path Transformation

The PathRemovePrefix transform strips the service name from URLs:

Request: http://localhost:8080/identity/auth/login
After Transform: /auth/login
Forwarded To: http://localhost:53573/auth/login

This keeps service APIs unchanged - they don't need to know about the gateway prefix.

Error Handling Strategy

YARP provides several error handling options:

{
  "HttpRequest": {
    "Timeout": "00:01:00",
    "Version": "2",
    "VersionPolicy": "RequestVersionOrLower"
  },
  "HealthCheck": {
    "Active": {
      "Enabled": true,
      "Interval": "00:00:10",
      "Timeout": "00:00:05",
      "Policy": "ConsecutiveFailures",
      "Path": "/health"
    }
  }
}

For this implementation, we'll use defaults initially and add health checks in a future iteration.


Port Assignments

Gateway

  • Port: 8080 (fixed)
  • URL: http://localhost:8080

Services

  • Ports: Dynamic (assigned by Aspire)
  • Discovery: Accessed via gateway or service discovery
  • No manual port management needed

Migration Guide

For Developers

Old workflow:

  1. Start Aspire
  2. Open Aspire dashboard
  3. Find Identity service port (e.g., 53573)
  4. Update http-client.env.json with new port
  5. Run HTTP requests

New workflow:

  1. Start Aspire
  2. Run HTTP requests using http://localhost:8080/identity/*
  3. Done!

For HTTP Client Files

Update all existing baseUrl entries:

Pattern:

{
  "dev": {
    "baseUrl": "http://localhost:8080/{service-name}"
  }
}

Service name mappings:

  • Identity → identity
  • CoreData → core-data
  • SystemEnvironment → system-environment
  • Spatial → spatial
  • Events → events
  • Catalog → catalog
  • Edge → edge
  • Bbu → bbu
  • DocWeb → docs (shorter, more friendly)
  • Importer → importer

For Service-to-Service Communication

No changes required! Services continue using Aspire service discovery:

// This still works unchanged
var response = await httpClient.GetAsync("https+http://identity/auth/me");

Future Enhancements

Phase 2: Optional Caddy Layer for Subdomains

If developers want even nicer URLs (http://identity.localhost instead of http://localhost:8080/identity):

Caddyfile:

identity.localhost {
    reverse_proxy localhost:8080/identity
}

core-data.localhost {
    reverse_proxy localhost:8080/core-data
}

# etc.

Pros:

  • Prettiest URLs possible
  • Production-like domain structure
  • Easy to remember

Cons:

  • Another tool to install/run
  • Optional enhancement, not required

Phase 3: CORS Configuration

Add CORS middleware to gateway for browser-based clients:

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("http://localhost:3000") // Next.js UI
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

app.UseCors();

Phase 4: Rate Limiting

Add rate limiting to protect services:

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", opt =>
    {
        opt.Window = TimeSpan.FromSeconds(10);
        opt.PermitLimit = 100;
    });
});

app.UseRateLimiter();

Phase 5: Centralized Authentication

Add JWT validation at gateway level:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Validate tokens at gateway
        // Forward claims to backend services
    });

This would allow services to trust gateway-validated tokens without re-validating.


Performance Considerations

Latency

  • Expected overhead: 1-3ms per request (YARP is very fast)
  • Service-to-service: Zero overhead (bypass gateway)
  • Acceptable for development: Yes

Throughput

  • YARP can handle 100k+ requests/second
  • More than sufficient for local development

Resource Usage

  • Memory: ~50-100MB
  • CPU: Minimal (<1% idle, <5% under load)

Troubleshooting Guide

Gateway Not Starting

Symptom: Gateway doesn't appear in Aspire dashboard

Check:

  1. Project reference added to AppHost
  2. .WithGateway<>() called in EdgeLiteBuilder
  3. Build successful (no compilation errors)

Solution: Review AppHost configuration, check build output

Service Route Not Working

Symptom: 404 Not Found when accessing service via gateway

Check:

  1. Service is running (visible in Aspire dashboard)
  2. Route configured in appsettings.json
  3. Path prefix matches (e.g., /identity/ not /Identity/)

Solution:

  1. Check Aspire dashboard - is service healthy?
  2. Verify appsettings.json route configuration
  3. Check YARP logs for routing decisions

Service Discovery Failing

Symptom: 503 Service Unavailable

Check:

  1. Service discovery configured correctly
  2. Service name matches exactly (case-sensitive)
  3. Service has HTTP endpoint registered

Solution:

  1. Check service name in Aspire dashboard
  2. Verify https+http://service-name format
  3. Review Gateway logs for discovery errors

Slow Requests

Symptom: Requests taking longer than expected

Check:

  1. Service itself slow (check direct access)
  2. Network issues (unlikely on localhost)
  3. YARP logging overhead (disable verbose logging)

Solution:

  1. Test service directly (bypass gateway)
  2. Reduce YARP logging level
  3. Check service health and performance

Documentation Updates Required

After implementation, update:

  1. Developer Onboarding Guide

    • Add section on gateway usage
    • Update "Starting the application" instructions
    • Add troubleshooting section
  2. HTTP Client Usage Guide

    • Document new URL format
    • Provide examples for each service
    • Explain when to use gateway vs service discovery
  3. Architecture Documentation

    • Add gateway to architecture diagrams
    • Explain request flow
    • Document service discovery integration
  4. README.md

    • Update "Running the Application" section
    • Add gateway URL list
    • Link to detailed documentation

Success Criteria

Implementation is complete when:

✅ Gateway service builds and runs
✅ Gateway appears in Aspire dashboard
✅ Gateway binds to port 8080
✅ All service routes configured
✅ HTTP client files updated
✅ Login flow works via gateway
✅ Protected endpoints work via gateway
✅ Service-to-service communication unchanged
✅ Health checks passing
✅ Documentation updated
✅ Team can use without port lookups


Estimated Effort

  • Gateway Project Setup: 30 minutes
  • YARP Configuration: 1 hour
  • EdgeLiteBuilder Integration: 30 minutes
  • HTTP Client Updates: 30 minutes
  • Testing: 1 hour
  • Documentation: 1 hour
  • Total: ~4.5 hours

Dependencies

  • Blocked By: Base component restructuring (currently in progress)
  • Requires: .NET 9.0, Aspire 9.0, YARP 2.3.0
  • Impacts: All developers, all HTTP client files

Notes

  • This plan assumes the current Aspire setup remains largely unchanged
  • If base restructuring changes service naming or organization, routes may need adjustment
  • Gateway can be added incrementally - doesn't require all services at once
  • Consider starting with just Identity and CoreData, then expanding

Questions to Resolve Before Implementation

  1. Should gateway support both HTTP and HTTPS?
  2. Do we want health check integration from day one?
  3. Should doc-web use /docs or /doc-web?
  4. Any services that should NOT be exposed via gateway?
  5. Do we want request/response logging enabled by default?

References