From d666e71446d1d1c8ccf65027055cef0fe4e355e8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 4 Jun 2026 09:44:12 +0200 Subject: [PATCH 1/2] test(acl): OpenZaak default-fills ZGW fields and posts to OpenZaak (refs #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ACL skeleton (Application/Infrastructure/Api per §9) and failing xUnit tests for the single operation: AclService.OpenZaakAsync must default-fill bronorganisatie, verantwoordelijkeOrganisatie, startdatum (clock) and vertrouwelijkheidaanduiding and delegate to the gateway; the OpenZaakGateway must POST the zaak to OpenZaak with a Bearer JWT and the default-filled body. Implementations throw NotImplementedException — red. Also add ADR-0003 (default-fill strategy). Co-Authored-By: Claude Opus 4.8 --- docs/architecture/adr-0003-default-fill.md | 44 ++++++++++++++++ services/acl/Acl.Api/Acl.Api.csproj | 14 +++++ services/acl/Acl.Api/Program.cs | 31 +++++++++++ .../Acl.Api/Properties/launchSettings.json | 23 +++++++++ .../acl/Acl.Api/appsettings.Development.json | 8 +++ services/acl/Acl.Api/appsettings.json | 9 ++++ .../Acl.Application/Acl.Application.csproj | 9 ++++ services/acl/Acl.Application/AclDefaults.cs | 10 ++++ services/acl/Acl.Application/AclService.cs | 9 ++++ .../acl/Acl.Application/DomainRegistration.cs | 4 ++ services/acl/Acl.Application/IClock.cs | 7 +++ services/acl/Acl.Application/IZaakGateway.cs | 8 +++ services/acl/Acl.Application/ZaakRequest.cs | 9 ++++ .../Acl.Infrastructure.csproj | 13 +++++ .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 10 ++++ .../acl/Acl.Infrastructure/OpenZaakOptions.cs | 9 ++++ .../acl/Acl.Infrastructure/SystemClock.cs | 8 +++ services/acl/Acl.Infrastructure/ZgwToken.cs | 29 +++++++++++ services/acl/Acl.Tests/Acl.Tests.csproj | 26 ++++++++++ services/acl/Acl.Tests/AclServiceTests.cs | 47 +++++++++++++++++ .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 51 +++++++++++++++++++ services/acl/Acl.slnx | 6 +++ 22 files changed, 384 insertions(+) create mode 100644 docs/architecture/adr-0003-default-fill.md create mode 100644 services/acl/Acl.Api/Acl.Api.csproj create mode 100644 services/acl/Acl.Api/Program.cs create mode 100644 services/acl/Acl.Api/Properties/launchSettings.json create mode 100644 services/acl/Acl.Api/appsettings.Development.json create mode 100644 services/acl/Acl.Api/appsettings.json create mode 100644 services/acl/Acl.Application/Acl.Application.csproj create mode 100644 services/acl/Acl.Application/AclDefaults.cs create mode 100644 services/acl/Acl.Application/AclService.cs create mode 100644 services/acl/Acl.Application/DomainRegistration.cs create mode 100644 services/acl/Acl.Application/IClock.cs create mode 100644 services/acl/Acl.Application/IZaakGateway.cs create mode 100644 services/acl/Acl.Application/ZaakRequest.cs create mode 100644 services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj create mode 100644 services/acl/Acl.Infrastructure/OpenZaakGateway.cs create mode 100644 services/acl/Acl.Infrastructure/OpenZaakOptions.cs create mode 100644 services/acl/Acl.Infrastructure/SystemClock.cs create mode 100644 services/acl/Acl.Infrastructure/ZgwToken.cs create mode 100644 services/acl/Acl.Tests/Acl.Tests.csproj create mode 100644 services/acl/Acl.Tests/AclServiceTests.cs create mode 100644 services/acl/Acl.Tests/OpenZaakGatewayTests.cs create mode 100644 services/acl/Acl.slnx diff --git a/docs/architecture/adr-0003-default-fill.md b/docs/architecture/adr-0003-default-fill.md new file mode 100644 index 0000000..c37e9bb --- /dev/null +++ b/docs/architecture/adr-0003-default-fill.md @@ -0,0 +1,44 @@ +# ADR-0003: ACL default-fill strategy + +- **Status:** Accepted +- **Date:** 2026-06-04 +- **Deciders:** Respellion engineering +- **Relates to:** S-04 (#5); builds on ADR-0001 (loose coupling) + +## Context + +The ACL is the only code that talks to ZGW APIs (ADR-0001 / CLAUDE.md §8.1). When the +domain asks it to "open a zaak", the domain payload is intentionally free of ZGW +specifics — it carries domain facts (e.g. the registrant's BSN), not OpenZaak fields. But +OpenZaak's `POST /zaken` requires ZGW-mandatory fields: `bronorganisatie`, +`verantwoordelijkeOrganisatie`, `startdatum`, `vertrouwelijkheidaanduiding`, and a +`zaaktype` URL. Something has to supply those, and it must not leak into the domain. + +## Decision + +**The ACL default-fills the ZGW-mandatory zaak fields; the domain never sees them.** + +- `bronorganisatie`, `verantwoordelijkeOrganisatie`, `vertrouwelijkheidaanduiding`, and the + `zaaktype` URL come from **ACL configuration** (`AclDefaults` options) — not hardcoded, + not from the domain. This keeps them operationally manageable (the beheer portal will + edit them in S-15) and environment-specific (the seeded BIG zaaktype URL differs per env). +- `startdatum` is derived from an injected **clock** (today's date), so it is + deterministic in tests. +- The mapping from domain payload → ZGW `ZaakRequest` lives entirely inside the ACL + (`Application` builds the request from payload + defaults; `Infrastructure` serialises and + POSTs it). No other service constructs ZGW payloads or URLs. + +## Consequences + +- **Positive:** the domain stays ZGW-agnostic; ZGW knowledge is in one named place; defaults + are config (testable, env-specific, later editable via the beheer portal). +- **Cost:** the ACL must be configured per environment (the seeded zaaktype URL, the + organisation RSINs). Missing/invalid config fails fast at the ACL boundary. +- **Follow-ups:** mapping the BSN onto the zaak (eigenschap/rol), status transitions, and + documents are explicitly out of scope for S-04 and get their own slices. + +## Alternatives considered + +- **Defaults in the domain payload** — rejected: leaks ZGW concerns into the domain, + violating ADR-0001. +- **Hardcoded defaults in code** — rejected: not env-specific, not operationally editable. diff --git a/services/acl/Acl.Api/Acl.Api.csproj b/services/acl/Acl.Api/Acl.Api.csproj new file mode 100644 index 0000000..0a0116c --- /dev/null +++ b/services/acl/Acl.Api/Acl.Api.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs new file mode 100644 index 0000000..31385d9 --- /dev/null +++ b/services/acl/Acl.Api/Program.cs @@ -0,0 +1,31 @@ +using Acl.Application; +using Acl.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl:Defaults").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl:Defaults'")); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl:OpenZaak").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapGet("/health", () => "Healthy"); + +// The ACL's single operation, exposed as a service endpoint. +app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => +{ + var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); + return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); +}); + +app.Run(); + +public sealed record OpenZaakRequest(string Bsn); + +public partial class Program; diff --git a/services/acl/Acl.Api/Properties/launchSettings.json b/services/acl/Acl.Api/Properties/launchSettings.json new file mode 100644 index 0000000..59ed1e7 --- /dev/null +++ b/services/acl/Acl.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7260;http://localhost:5041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/services/acl/Acl.Api/appsettings.Development.json b/services/acl/Acl.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/services/acl/Acl.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/services/acl/Acl.Api/appsettings.json b/services/acl/Acl.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/services/acl/Acl.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/services/acl/Acl.Application/Acl.Application.csproj b/services/acl/Acl.Application/Acl.Application.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/services/acl/Acl.Application/Acl.Application.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/services/acl/Acl.Application/AclDefaults.cs b/services/acl/Acl.Application/AclDefaults.cs new file mode 100644 index 0000000..232d3a4 --- /dev/null +++ b/services/acl/Acl.Application/AclDefaults.cs @@ -0,0 +1,10 @@ +namespace Acl.Application; + +/// Configured ZGW defaults the ACL fills in (ADR-0003). +public sealed class AclDefaults +{ + public required string Bronorganisatie { get; init; } + public required string VerantwoordelijkeOrganisatie { get; init; } + public required string Vertrouwelijkheidaanduiding { get; init; } + public required Uri ZaaktypeUrl { get; init; } +} diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs new file mode 100644 index 0000000..effb09d --- /dev/null +++ b/services/acl/Acl.Application/AclService.cs @@ -0,0 +1,9 @@ +namespace Acl.Application; + +/// The ACL's single operation: open a zaak from a domain payload, +/// default-filling the ZGW-mandatory fields (ADR-0003). +public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock) +{ + public Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) + => throw new NotImplementedException(); +} diff --git a/services/acl/Acl.Application/DomainRegistration.cs b/services/acl/Acl.Application/DomainRegistration.cs new file mode 100644 index 0000000..de985f0 --- /dev/null +++ b/services/acl/Acl.Application/DomainRegistration.cs @@ -0,0 +1,4 @@ +namespace Acl.Application; + +/// Domain-language payload handed to the ACL. No ZGW concepts here. +public sealed record DomainRegistration(string Bsn); diff --git a/services/acl/Acl.Application/IClock.cs b/services/acl/Acl.Application/IClock.cs new file mode 100644 index 0000000..bd93e9c --- /dev/null +++ b/services/acl/Acl.Application/IClock.cs @@ -0,0 +1,7 @@ +namespace Acl.Application; + +/// Abstracts "today" so startdatum default-fill is deterministic in tests. +public interface IClock +{ + DateOnly Today { get; } +} diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs new file mode 100644 index 0000000..1962488 --- /dev/null +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -0,0 +1,8 @@ +namespace Acl.Application; + +/// Port to the ZGW Zaken API. Implemented in Infrastructure — the only +/// code that talks to OpenZaak (ADR-0001). +public interface IZaakGateway +{ + Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default); +} diff --git a/services/acl/Acl.Application/ZaakRequest.cs b/services/acl/Acl.Application/ZaakRequest.cs new file mode 100644 index 0000000..875d162 --- /dev/null +++ b/services/acl/Acl.Application/ZaakRequest.cs @@ -0,0 +1,9 @@ +namespace Acl.Application; + +/// The fully default-filled zaak the gateway will create in OpenZaak. +public sealed record ZaakRequest( + string Bronorganisatie, + string VerantwoordelijkeOrganisatie, + string Vertrouwelijkheidaanduiding, + Uri Zaaktype, + DateOnly Startdatum); diff --git a/services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj b/services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj new file mode 100644 index 0000000..6321050 --- /dev/null +++ b/services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs new file mode 100644 index 0000000..3332a1e --- /dev/null +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -0,0 +1,10 @@ +using Acl.Application; + +namespace Acl.Infrastructure; + +/// The only code that talks to OpenZaak's Zaken API (ADR-0001). +public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway +{ + public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) + => throw new NotImplementedException(); +} diff --git a/services/acl/Acl.Infrastructure/OpenZaakOptions.cs b/services/acl/Acl.Infrastructure/OpenZaakOptions.cs new file mode 100644 index 0000000..66f9dc6 --- /dev/null +++ b/services/acl/Acl.Infrastructure/OpenZaakOptions.cs @@ -0,0 +1,9 @@ +namespace Acl.Infrastructure; + +/// Connection + credential config for OpenZaak's ZGW APIs. +public sealed class OpenZaakOptions +{ + public required Uri BaseUrl { get; init; } + public required string ClientId { get; init; } + public required string Secret { get; init; } +} diff --git a/services/acl/Acl.Infrastructure/SystemClock.cs b/services/acl/Acl.Infrastructure/SystemClock.cs new file mode 100644 index 0000000..47939d3 --- /dev/null +++ b/services/acl/Acl.Infrastructure/SystemClock.cs @@ -0,0 +1,8 @@ +using Acl.Application; + +namespace Acl.Infrastructure; + +public sealed class SystemClock : IClock +{ + public DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); +} diff --git a/services/acl/Acl.Infrastructure/ZgwToken.cs b/services/acl/Acl.Infrastructure/ZgwToken.cs new file mode 100644 index 0000000..a784fd5 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ZgwToken.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Acl.Infrastructure; + +/// Mints a ZGW (vng-api-common) JWT: HS256 over the standard claims. +internal static class ZgwToken +{ + public static string Mint(string clientId, string secret) + { + var header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" })); + var payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new + { + iss = clientId, + iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + client_id = clientId, + user_id = "acl", + user_representation = "acl", + })); + var signingInput = $"{header}.{payload}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var signature = B64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput))); + return $"{signingInput}.{signature}"; + } + + private static string B64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/services/acl/Acl.Tests/Acl.Tests.csproj b/services/acl/Acl.Tests/Acl.Tests.csproj new file mode 100644 index 0000000..22e2588 --- /dev/null +++ b/services/acl/Acl.Tests/Acl.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs new file mode 100644 index 0000000..e1348e1 --- /dev/null +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -0,0 +1,47 @@ +using Acl.Application; + +namespace Acl.Tests; + +public class AclServiceTests +{ + private sealed class FakeGateway : IZaakGateway + { + public ZaakRequest? Captured; + public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); + + public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) + { + Captured = request; + return Task.FromResult(Result); + } + } + + private sealed class FixedClock(DateOnly today) : IClock + { + public DateOnly Today { get; } = today; + } + + [Fact] + public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url() + { + var gateway = new FakeGateway(); + var defaults = new AclDefaults + { + Bronorganisatie = "517439943", + VerantwoordelijkeOrganisatie = "517439943", + Vertrouwelijkheidaanduiding = "openbaar", + ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), + }; + var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + + var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); + + Assert.Equal(gateway.Result, url); + var req = Assert.IsType(gateway.Captured); + Assert.Equal("517439943", req.Bronorganisatie); + Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie); + Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); + Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); + Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); + } +} diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs new file mode 100644 index 0000000..b6aa129 --- /dev/null +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Net.Http.Json; +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.Tests; + +public class OpenZaakGatewayTests +{ + private sealed class StubHandler(Func> onSend) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); + } + + [Fact] + public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url() + { + HttpRequestMessage? seen = null; + string? body = null; + var handler = new StubHandler(async req => + { + seen = req; + body = await req.Content!.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }), + }; + }); + var gateway = new OpenZaakGateway( + new HttpClient(handler), + new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" }); + var request = new ZaakRequest( + "517439943", "517439943", "openbaar", + new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); + + var url = await gateway.OpenZaakAsync(request); + + Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString()); + Assert.Equal(HttpMethod.Post, seen!.Method); + Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString()); + Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme); + Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter)); + Assert.Contains("\"bronorganisatie\":\"517439943\"", body); + Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body); + Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body); + Assert.Contains("\"startdatum\":\"2026-06-04\"", body); + Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", body); + } +} diff --git a/services/acl/Acl.slnx b/services/acl/Acl.slnx new file mode 100644 index 0000000..d33e693 --- /dev/null +++ b/services/acl/Acl.slnx @@ -0,0 +1,6 @@ + + + + + + -- 2.49.1 From 385708c6264447c42a0a32105520aa1753049a6f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 4 Jun 2026 09:48:30 +0200 Subject: [PATCH 2/2] feat(acl): implement OpenZaak default-fill + ZGW gateway (refs #5) AclService.OpenZaakAsync builds a ZaakRequest from the configured defaults (bronorganisatie, verantwoordelijkeOrganisatie, vertrouwelijkheidaanduiding, zaaktype) plus today's date from the clock, and delegates to the gateway. OpenZaakGateway POSTs the zaak to /zaken/api/v1/zaken with a ZGW Bearer JWT and the CRS headers, returning the created zaak URL. Tests pass (green). Add a root register-referentie.slnx (bff + acl) and point `make ci` at it so the gate covers every .NET service. Co-Authored-By: Claude Opus 4.8 --- Makefile | 2 +- register-referentie.slnx | 13 ++++++ services/acl/Acl.Application/AclService.cs | 13 +++++- .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 42 ++++++++++++++++++- 4 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 register-referentie.slnx diff --git a/Makefile b/Makefile index 60a648e..49442f5 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # `make ci` locally runs exactly what the pipeline runs — no drift. Until a # self-hosted runner is registered, `make ci` is the gate (see docs/runbooks/ci.md). -SLN := services/bff/Bff.slnx +SLN := register-referentie.slnx COMPOSE := infra/docker-compose.yml HEALTH_URL := http://localhost:8080/health OZ_COMPOSE := infra/openzaak/docker-compose.yml diff --git a/register-referentie.slnx b/register-referentie.slnx new file mode 100644 index 0000000..bf80fac --- /dev/null +++ b/register-referentie.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index effb09d..e285ee7 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -5,5 +5,16 @@ namespace Acl.Application; public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock) { public Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) - => throw new NotImplementedException(); + { + ArgumentNullException.ThrowIfNull(registration); + + var request = new ZaakRequest( + defaults.Bronorganisatie, + defaults.VerantwoordelijkeOrganisatie, + defaults.Vertrouwelijkheidaanduiding, + defaults.ZaaktypeUrl, + clock.Today); + + return gateway.OpenZaakAsync(request, ct); + } } diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 3332a1e..0a6e2e4 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -1,3 +1,6 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; using Acl.Application; namespace Acl.Infrastructure; @@ -5,6 +8,41 @@ namespace Acl.Infrastructure; /// The only code that talks to OpenZaak's Zaken API (ADR-0001). public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway { - public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + using var message = new HttpRequestMessage( + HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/zaken")) + { + Content = JsonContent.Create(new ZaakDto( + request.Bronorganisatie, + request.Zaaktype.ToString(), + request.VerantwoordelijkeOrganisatie, + request.Startdatum.ToString("yyyy-MM-dd"), + request.Vertrouwelijkheidaanduiding)), + }; + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + // ZRC is a geo API; it requires the CRS headers. + message.Headers.Add("Accept-Crs", "EPSG:4326"); + message.Content.Headers.Add("Content-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + + var created = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("OpenZaak returned an empty zaak response"); + return new Uri(created.Url); + } + + private sealed record ZaakDto( + [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, + [property: JsonPropertyName("zaaktype")] string Zaaktype, + [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, + [property: JsonPropertyName("startdatum")] string Startdatum, + [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); + + private sealed record ZaakCreatedDto( + [property: JsonPropertyName("url")] string Url); } -- 2.49.1