diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 81aad4905..890568116 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "swashbuckle.aspnetcore.cli": { - "version": "6.6.2", + "version": "7.1.0", "commands": [ "swagger" ], diff --git a/.github/workflows/openapi-md.yml b/.github/workflows/openapi-md.yml index 8645cd43b..0a98c18d6 100644 --- a/.github/workflows/openapi-md.yml +++ b/.github/workflows/openapi-md.yml @@ -3,63 +3,63 @@ # The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. # See the LICENSE and NOTICES files in the project root for more information. -name: Create PR to update doc and openapi definition +name: Generate OpenAPI definitions on: workflow_dispatch: inputs: version: - description: 'Version Name. Example -> 2.2.2 will result "admin-api-2.2.2.yaml" "admin-api-2.2.2-summary.md"' - required: true + description: 'Version (e.g. "2.4.0", checks out tag v2.4.0), a branch name (checked out as-is), or blank for latest (checks out main).' + required: false type: string -permissions: read-all + schedule: + - cron: '0 6 * * 0' # Sunday 06:00 UTC -env: - CI_COMMIT_AUTHOR: github-actions[bot] - CI_COMMIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} +permissions: read-all jobs: - create-doc-and-openapiyaml: - name: Generate documentation + generate-openapi: + name: Generate OpenAPI v2/v3 specs runs-on: ubuntu-latest - permissions: - contents: write defaults: run: shell: pwsh steps: - - name: Checkout the Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Validate version - id: validate-version + - name: Resolve ref and version + id: resolve-version env: INPUT_VERSION: ${{ inputs.version }} run: | $version = $env:INPUT_VERSION - if ($version -notmatch '^\d+\.\d+\.\d+$') + if ([string]::IsNullOrWhiteSpace($version)) + { + $ref = "main" + $versionLabel = "latest" + } + elseif ($version -match '^\d+\.\d+\.\d+$') + { + $ref = "v$version" + $versionLabel = $version + } + else { - throw "Invalid version format: $version" + $ref = $version + $versionLabel = ($version -replace '[\\/]', '-') } - "version=$version" >> $env:GITHUB_OUTPUT - "branch-name=openapi-$version" >> $env:GITHUB_OUTPUT - "commit-message=Add YAML and markdown file api-specification version $version" >> $env:GITHUB_OUTPUT + "ref=$ref" >> $env:GITHUB_OUTPUT + "version=$versionLabel" >> $env:GITHUB_OUTPUT - - name: Git create branch - run: | - git checkout -b "${{ steps.validate-version.outputs.branch-name }}" - git push --set-upstream origin "${{ steps.validate-version.outputs.branch-name }}" + - name: Checkout the Repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ steps.resolve-version.outputs.ref }} - name: Install Swashbuckle CLI - run: dotnet tool install Swashbuckle.AspNetCore.Cli --version 6.6.2 --create-manifest-if-needed + run: dotnet tool install Swashbuckle.AspNetCore.Cli --version 7.1.0 --create-manifest-if-needed - - name: Install widdershins CLI - run: npm install -g widdershins - - - name: Build and generate YAML and MD files + - name: Build and generate YAML files run: | $p = @{ Authority = "http://api" @@ -70,23 +70,13 @@ jobs: AdminDB = "host=db-admin;port=5432;username=username;password=password;database=EdFi_Admin;Application Name=EdFi.Ods.AdminApi;" SecurityDB = "host=db-admin;port=5432;username=username;password=password;database=EdFi_Security;Application Name=EdFi.Ods.AdminApi;" } - ./build.ps1 -APIVersion "${{ steps.validate-version.outputs.version }}" -Configuration Release -DockerEnvValues $p -Command GenerateOpenAPIAndMD - - - name: Git add files - run: | - git add docs/api-specifications/openapi-yaml/* - git add docs/api-specifications/markdown/* - git restore Application/EdFi.Ods.AdminApi/appsettings.json - git status --porcelain + ./build.ps1 -APIVersion "${{ steps.resolve-version.outputs.version }}" -Configuration Release -DockerEnvValues $p -Command GenerateOpenAPI - - name: Commit file - id: commit - uses: planetscale/ghcommit-action@25309d8005ac7c3bcd61d3fe19b69e0fe47dbdde # v0.2.20 + - name: Upload OpenAPI artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - commit_message: "${{ steps.validate-version.outputs.commit-message }}" - repo: ${{ github.repository }} - branch: ${{ steps.validate-version.outputs.branch-name }} - file_pattern: '*.yaml *.md' - - - name: Create PR - run: gh pr create -B main -H "${{ steps.validate-version.outputs.branch-name }}" --title "[Github Action] Open API documentation version ${{ steps.validate-version.outputs.version }}" --body 'Created by Github action' + name: admin-api-openapi-${{ steps.resolve-version.outputs.version }} + path: | + docs/api-specifications/openapi-yaml/admin-api-v2-${{ steps.resolve-version.outputs.version }}.yaml + docs/api-specifications/openapi-yaml/admin-api-v3-${{ steps.resolve-version.outputs.version }}.yaml + if-no-files-found: error diff --git a/.gitignore b/.gitignore index e0cc53e93..a84a4b84e 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ coveragereport/ # Cache files *.lscache + +# Superpowers subagent-driven-development workspace +.superpowers/ diff --git a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/EndpointRouteBuilderExtensions.cs b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/EndpointRouteBuilderExtensions.cs index d39696a4e..5964d8080 100644 --- a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/EndpointRouteBuilderExtensions.cs +++ b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/EndpointRouteBuilderExtensions.cs @@ -11,17 +11,25 @@ namespace EdFi.Ods.AdminApi.Common.Infrastructure; public static class EndpointRouteBuilderExtensions { - public static RouteHandlerBuilder WithResponseCode(this RouteHandlerBuilder builder, int code, string? description = null) + public static RouteHandlerBuilder WithResponseCode(this RouteHandlerBuilder builder, int code, string? description = null, string? locationDescription = null) { builder.Produces(code); builder.WithMetadata(new SwaggerResponseAttribute(code, description)); + AddLocationHeaderDescription(builder, code, locationDescription); return builder; } - public static RouteHandlerBuilder WithResponse(this RouteHandlerBuilder builder, int code, string? description = null) + public static RouteHandlerBuilder WithResponse(this RouteHandlerBuilder builder, int code, string? description = null, string? locationDescription = null) { builder.Produces(code, responseType: typeof(T)); builder.WithMetadata(new SwaggerResponseAttribute(code, description, typeof(T))); + AddLocationHeaderDescription(builder, code, locationDescription); return builder; } + + private static void AddLocationHeaderDescription(RouteHandlerBuilder builder, int code, string? locationDescription) + { + if (code == 201 && locationDescription is not null) + builder.WithMetadata(new LocationHeaderDescriptionMetadata(locationDescription)); + } } diff --git a/Application/EdFi.Ods.AdminApi.Common/Infrastructure/LocationHeaderDescriptionMetadata.cs b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/LocationHeaderDescriptionMetadata.cs new file mode 100644 index 000000000..73f0a3cc0 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi.Common/Infrastructure/LocationHeaderDescriptionMetadata.cs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +namespace EdFi.Ods.AdminApi.Common.Infrastructure; + +/// +/// Endpoint metadata overriding the OpenAPI description of the "Location" header +/// documented on a 201 response. Used for endpoints where Location does not point +/// at the resource that was created (e.g. a queued job's status endpoint). +/// +public class LocationHeaderDescriptionMetadata(string description) +{ + public string Description { get; } = description; +} diff --git a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/RefreshEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/RefreshEducationOrganizations.cs index f1b3d089e..65f9e8ec6 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/RefreshEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Features/DataStores/RefreshEducationOrganizations.cs @@ -18,6 +18,12 @@ namespace EdFi.Ods.AdminApi.V3.Features.DataStores; public class RefreshEducationOrganizations : IFeature { + public class JobQueuedResult + { + public string JobId { get; set; } = null!; + public string Message { get; set; } = null!; + } + public void MapEndpoints(IEndpointRouteBuilder endpoints) { AdminApiEndpointBuilder @@ -26,7 +32,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) "Refreshes education organizations for all data stores", "Triggers a refresh of education organization data from all data stores" ) - .WithRouteOptions(b => b.WithResponseCode(201)) + .WithRouteOptions(b => b.WithResponse(201, locationDescription: "URI of the queued job's status endpoint.")) .BuildForVersions(AdminApiVersions.V3); AdminApiEndpointBuilder @@ -36,7 +42,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) "Triggers a refresh of education organization data for the specified data store" ) .WithRouteOptions(b => b - .WithResponseCode(201) + .WithResponse(201, locationDescription: "URI of the queued job's status endpoint.") .WithResponseCode(404)) .BuildForVersions(AdminApiVersions.V3); } @@ -64,10 +70,10 @@ public static async Task RefreshAllEducationOrganizations( var scheduler = await schedulerFactory.GetScheduler(); await scheduler.ScheduleJob(job, trigger); - var response = new + var response = new JobQueuedResult { - jobId, - message = "Education organizations refresh has been queued for all instances" + JobId = jobId, + Message = "Education organizations refresh has been queued for all instances" }; var locationUri = $"/v3/jobs/{jobId}"; @@ -106,10 +112,10 @@ public static async Task RefreshEducationOrganizationsByDataStore( var scheduler = await schedulerFactory.GetScheduler(); await scheduler.ScheduleJob(job, trigger); - var response = new + var response = new JobQueuedResult { - jobId, - message = "Education organizations refresh has been queued for the specified instance" + JobId = jobId, + Message = "Education organizations refresh has been queued for the specified instance" }; var locationUri = $"/v3/jobs/{jobId}"; diff --git a/Application/EdFi.Ods.AdminApi.V3/Features/Tenants/ReadTenants.cs b/Application/EdFi.Ods.AdminApi.V3/Features/Tenants/ReadTenants.cs index 006f20bc2..aae447a4a 100644 --- a/Application/EdFi.Ods.AdminApi.V3/Features/Tenants/ReadTenants.cs +++ b/Application/EdFi.Ods.AdminApi.V3/Features/Tenants/ReadTenants.cs @@ -26,6 +26,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) { AdminApiEndpointBuilder .MapGet(endpoints, "/tenants/{tenantName}/dataStores/edOrgs", GetTenantEdOrgsByDataStoresAsync) + .WithRouteOptions(b => b.WithResponse(200)) .BuildForVersions(AdminApiVersions.V3); } diff --git a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/RefreshEducationOrganizations.cs b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/RefreshEducationOrganizations.cs index 898a385aa..f46908b78 100644 --- a/Application/EdFi.Ods.AdminApi/Features/OdsInstances/RefreshEducationOrganizations.cs +++ b/Application/EdFi.Ods.AdminApi/Features/OdsInstances/RefreshEducationOrganizations.cs @@ -26,7 +26,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) "Refreshes education organizations for all ODS instances", "Triggers a refresh of education organization data from all ODS instances" ) - .WithRouteOptions(b => b.WithResponseCode(201)) + .WithRouteOptions(b => b.WithResponseCode(201, locationDescription: "URI of the queued job's status endpoint.")) .BuildForVersions(AdminApiVersions.V2); AdminApiEndpointBuilder @@ -36,7 +36,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) "Triggers a refresh of education organization data for the specified ODS instance" ) .WithRouteOptions(b => b - .WithResponseCode(201) + .WithResponseCode(201, locationDescription: "URI of the queued job's status endpoint.") .WithResponseCode(404)) .BuildForVersions(AdminApiVersions.V2); } diff --git a/Application/EdFi.Ods.AdminApi/Features/Tenants/ReadTenants.cs b/Application/EdFi.Ods.AdminApi/Features/Tenants/ReadTenants.cs index a3a9f474a..3a5b0900e 100644 --- a/Application/EdFi.Ods.AdminApi/Features/Tenants/ReadTenants.cs +++ b/Application/EdFi.Ods.AdminApi/Features/Tenants/ReadTenants.cs @@ -25,6 +25,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) { AdminApiEndpointBuilder .MapGet(endpoints, "/tenants/{tenantName}/odsInstances/edOrgs", GetTenantEdOrgsByInstancesAsync) + .WithRouteOptions(b => b.WithResponse(200)) .BuildForVersions(AdminApiVersions.V2); } diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/AnonymousOperationSecurityFilter.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/AnonymousOperationSecurityFilter.cs new file mode 100644 index 000000000..298b10cc1 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/AnonymousOperationSecurityFilter.cs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace EdFi.Ods.AdminApi.Infrastructure.Documentation; + +/// +/// Clears the document-wide OAuth security requirement on operations whose endpoint allows +/// anonymous access, so the generated spec doesn't imply a token is required to call them +/// (e.g. the token/register endpoints and the informational metadata endpoint). +/// +public class AnonymousOperationSecurityFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (context.ApiDescription.ActionDescriptor.EndpointMetadata.OfType().Any()) + { + // Microsoft.OpenApi's V3 writer skips the "security" property entirely when the list + // is empty (WriteOptionalCollection treats an empty collection the same as a missing + // one), so a genuinely empty list can't be serialized. A single empty requirement + // object ({}) is the OpenAPI-spec-legal equivalent: it overrides the document-level + // requirement and is satisfied without any scheme, i.e. "no auth required" here. + operation.Security = new List { new OpenApiSecurityRequirement() }; + } + } +} diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/LocationHeaderOperationFilter.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/LocationHeaderOperationFilter.cs new file mode 100644 index 000000000..cf5126bcd --- /dev/null +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/LocationHeaderOperationFilter.cs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using EdFi.Ods.AdminApi.Common.Infrastructure; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace EdFi.Ods.AdminApi.Infrastructure.Documentation; + +/// +/// Documents the "Location" header on 201 responses. Endpoints whose Location does not +/// point at the created resource (e.g. a queued job's status endpoint) can override the +/// description via . +/// +public class LocationHeaderOperationFilter : IOperationFilter +{ + private const string DefaultDescription = "URI of the resource that was created."; + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (!operation.Responses.TryGetValue("201", out var response)) + return; + + var descriptionOverride = context.ApiDescription.ActionDescriptor.EndpointMetadata + .OfType() + .FirstOrDefault() + ?.Description; + + response.Headers ??= new Dictionary(); + response.Headers["Location"] = new OpenApiHeader + { + Description = descriptionOverride ?? DefaultDescription, + Schema = new OpenApiSchema { Type = "string", Format = "uri" } + }; + } +} diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/ProblemDetailsResponseOperationFilter.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/ProblemDetailsResponseOperationFilter.cs new file mode 100644 index 000000000..7701f7719 --- /dev/null +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/Documentation/ProblemDetailsResponseOperationFilter.cs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace EdFi.Ods.AdminApi.Infrastructure.Documentation; + +/// +/// Documents the "application/problem+json" body that the API +/// actually returns for every 4xx/5xx response (see V3RequestErrorMiddleware), for any error +/// response that doesn't already declare its own content schema. +/// +public class ProblemDetailsResponseOperationFilter : IOperationFilter +{ + private const string ProblemJsonContentType = "application/problem+json"; + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var schema = context.SchemaGenerator.GenerateSchema(typeof(ProblemDetails), context.SchemaRepository); + + foreach (var (statusCode, response) in operation.Responses) + { + if (!IsErrorStatusCode(statusCode)) + continue; + + if (response.Content is { Count: > 0 }) + continue; + + response.Content = new Dictionary + { + [ProblemJsonContentType] = new OpenApiMediaType { Schema = schema } + }; + } + } + + private static bool IsErrorStatusCode(string statusCode) => + statusCode.Length == 3 && statusCode[0] is '4' or '5'; +} diff --git a/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs b/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs index 132eff0af..1a46970ce 100644 --- a/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs +++ b/Application/EdFi.Ods.AdminApi/Infrastructure/WebApplicationBuilderExtensions.cs @@ -48,7 +48,7 @@ namespace EdFi.Ods.AdminApi.Infrastructure; public static class WebApplicationBuilderExtensions { - private static readonly string[] _value = ["api"]; + private static readonly string[] _value = [SecurityConstants.Scopes.AdminApiFullAccess.Scope]; public static void AddServices(this WebApplicationBuilder webApplicationBuilder) { @@ -156,6 +156,9 @@ public static void AddServices(this WebApplicationBuilder webApplicationBuilder) }); opt.OperationFilter(); opt.OperationFilter(); + opt.OperationFilter(); + opt.OperationFilter(); + opt.OperationFilter(); opt.AddSecurityDefinition( "oauth", new OpenApiSecurityScheme diff --git a/build.ps1 b/build.ps1 index bc2498efc..ae654557e 100755 --- a/build.ps1 +++ b/build.ps1 @@ -69,14 +69,14 @@ SecurityDB = "host=db-admin;port=5432;username=username;password=password;database=EdFi_Security;Application Name=EdFi.Ods.AdminApi;" } - ./build.ps1 -APIVersion ${{ inputs.version }} -Configuration Release -DockerEnvValues $p -Command GenerateOpenAPIAndMD + ./build.ps1 -APIVersion ${{ inputs.version }} -Configuration Release -DockerEnvValues $p -Command GenerateOpenAPI #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'False positive')] param( # Command to execute, defaults to "Build". [string] - [ValidateSet("Clean", "Build", "GenerateOpenAPIAndMD", "BuildAndPublish", "UnitTest", "IntegrationTest", "PackageApi" + [ValidateSet("Clean", "Build", "GenerateOpenAPI", "BuildAndPublish", "UnitTest", "IntegrationTest", "PackageApi" , "Push", "BuildAndTest", "BuildAndDeployToAdminApiDockerContainer" , "BuildAndRunAdminApiDevDocker", "RunAdminApiDevDockerContainer", "RunAdminApiDevDockerCompose", "Run", "CopyToDockerContext", "RemoveDockerContextFiles")] $Command = "Build", @@ -213,13 +213,18 @@ function Compile { } function GenerateOpenAPI { + param( + [string] + $DocVersion + ) + Invoke-Execute { Push-Location $solutionRoot/EdFi.Ods.AdminApi/ - $outputOpenAPI = "../../docs/api-specifications/openapi-yaml/admin-api-$APIVersion.yaml" $dllPath = "./bin/Release/net10.0/EdFi.Ods.AdminApi.dll" + $outputOpenAPI = "../../docs/api-specifications/openapi-yaml/admin-api-$DocVersion-$APIVersion.yaml" try { - dotnet tool run swagger tofile --output $outputOpenAPI --yaml $dllPath v2 + dotnet tool run swagger tofile --output $outputOpenAPI --yaml $dllPath $DocVersion } finally { Pop-Location @@ -227,14 +232,6 @@ function GenerateOpenAPI { } } -function GenerateDocumentation { - Invoke-Execute { - $outputOpenAPI = "docs/api-specifications/openapi-yaml/admin-api-$APIVersion.yaml" - $outputMD = "docs/api-specifications/markdown/admin-api-$APIVersion-summary.md" - widdershins --search false --omitHeader true --code true --summary $outputOpenAPI -o $outputMD - } -} - function PublishAdminApi { Invoke-Execute { $project = "$solutionRoot/EdFi.Ods.AdminApi/" @@ -408,13 +405,15 @@ function Invoke-Build { Invoke-Step { Compile } } -function Invoke-GenerateOpenAPIAndMD { - Invoke-Step { UpdateAppSettingsForAdminApi } +function Invoke-GenerateOpenAPI { Invoke-Step { DotNetClean } Invoke-Step { Restore } - Invoke-Step { Compile } - Invoke-Step { GenerateOpenAPI } - Invoke-Step { GenerateDocumentation } + + foreach ($docVersion in @("v2", "v3")) { + Invoke-Step { UpdateAppSettingsForAdminApi -AdminApiMode $docVersion } + Invoke-Step { Compile } + Invoke-Step { GenerateOpenAPI -DocVersion $docVersion } + } } function Invoke-SetAssemblyInfo { @@ -539,11 +538,20 @@ function UpdateAppSettingsForAdminApiDocker { } function UpdateAppSettingsForAdminApi { + param( + [string] + $AdminApiMode + ) + $filePath = "$solutionRoot/EdFi.Ods.AdminApi/appsettings.json" $json = (Get-Content -Path $filePath) | ConvertFrom-Json $json.AppSettings.DatabaseEngine = $DockerEnvValues["DatabaseEngine"] $json.AppSettings.PathBase = $DockerEnvValues["PathBase"] + if ($AdminApiMode) { + $json.AppSettings.AdminApiMode = $AdminApiMode + } + $json.Authentication.IssuerUrl = $DockerEnvValues["IssuerUrl"] $json.Authentication.SigningKey = $DockerEnvValues["SigningKey"] @@ -641,7 +649,7 @@ Invoke-Main { switch ($Command) { Clean { Invoke-Clean } Build { Invoke-Build } - GenerateOpenAPIAndMD { Invoke-GenerateOpenAPIAndMD } + GenerateOpenAPI { Invoke-GenerateOpenAPI } BuildAndPublish { Invoke-SetAssemblyInfo Invoke-Build diff --git a/docs/design/2026-07-30-openapi-artifact-workflow-design.md b/docs/design/2026-07-30-openapi-artifact-workflow-design.md new file mode 100644 index 000000000..8f33d763e --- /dev/null +++ b/docs/design/2026-07-30-openapi-artifact-workflow-design.md @@ -0,0 +1,102 @@ +# OpenAPI Spec Generation Workflow (v2 + v3, Artifact Publishing) + +## Problem + +The existing `.github/workflows/openapi-md.yml` action is outdated: + +- It only generates the `v2` Swagger document (`build.ps1`'s `GenerateOpenAPI` function hardcodes `swagger tofile ... v2`), even though Admin API now exposes a real `v3` surface (`AdminApiVersions` registers `v1`, `v2`, `v3`, and v3 has its own endpoints, e.g. `/dataStores/manage`). +- It generates a markdown summary via `widdershins`, which is no longer wanted. +- It commits the generated files to a branch in this repo and opens a PR here, which is more process than needed for what is essentially a generated build artifact. + +## Goal + +A manually-dispatchable (and weekly-scheduled) GitHub Action that generates both the `v2` and `v3` OpenAPI yaml specs for Admin API and publishes them as downloadable **workflow run artifacts** — no markdown, no git commits, no PRs, and nothing pushed to any other repository. + +## `build.ps1` changes + +- **`adminApiMode` must be switched per doc generated.** `AppSettings:AdminApiMode` (`appsettings.json`) isn't cosmetic — `WebApplicationBuilderExtensions.cs:74/81` uses it to decide which endpoints get registered at startup at all (v2-only endpoints when `AdminApiMode=v2`, v3-only when `AdminApiMode=v3`). Since `swagger tofile` boots the real app pipeline to introspect routes, a single static `adminApiMode` can only ever produce a correct doc for one of the two versions. So generation must run in two passes, flipping the mode between them: + - `UpdateAppSettingsForAdminApi` (`build.ps1:541`, currently only called from `Invoke-GenerateOpenAPIAndMD`/`build.ps1:412`) gains an `-AdminApiMode` parameter that sets `json.AppSettings.AdminApiMode` when provided. No other call site exists, so this is a safe, non-breaking addition. + - `GenerateOpenAPI` (`build.ps1:215`) takes a `-DocVersion` parameter (`v2` or `v3`) instead of looping internally, and no longer needs to loop over both — the loop moves up to `Invoke-GenerateOpenAPI`. + - `Invoke-GenerateOpenAPI` (renamed from `Invoke-GenerateOpenAPIAndMD`, `build.ps1:411`) runs `DotNetClean`/`Restore` once, then for each of `v2`/`v3`: `UpdateAppSettingsForAdminApi -AdminApiMode ` → `Compile` (re-copies the edited `appsettings.json` into `bin/`, since it's `CopyToOutputDirectory: PreserveNewest` by ASP.NET Core SDK default — no full rebuild needed) → `GenerateOpenAPI -DocVersion `. + - Output files: `docs/api-specifications/openapi-yaml/admin-api-v2-$APIVersion.yaml` and `admin-api-v3-$APIVersion.yaml`. +- `GenerateDocumentation` (`build.ps1:230`, the `widdershins` call) is removed entirely. +- The `GenerateOpenAPIAndMD` command is renamed to `GenerateOpenAPI`: + - `ValidateSet` entry at `build.ps1:79` updated. + - Switch statement at `build.ps1:644` updated to match. +- `docs/yaml-to-md/yaml-to-md.md` (a standalone manual how-to doc for turning yaml into markdown by hand) is unrelated to this automated workflow and is left untouched. +- The workflow's `$p` `-DockerEnvValues` hashtable needs **no new key** for this — mode-switching is entirely internal to `-Command GenerateOpenAPI`. + +## Workflow changes (`.github/workflows/openapi-md.yml`) + +### Triggers + +```yaml +on: + workflow_dispatch: + inputs: + version: + description: 'Version name for output filenames, e.g. "2.4.0". Leave blank to use "latest" (always used for the scheduled run).' + required: false + type: string + schedule: + - cron: '0 6 * * 0' # Sunday 06:00 UTC +``` + +### Ref + version resolution + +Historically, `version` was also implicitly meant to let a run target a specific tagged release rather than always building whatever's on `main` — the old workflow just never actually implemented that (it always checked out the triggering branch). This redesign makes that explicit: the input now resolves to **both** a git ref to check out **and** a version label used for output filenames. This repo already tags releases as `vX.Y.Z` (confirmed via `git tag`, e.g. `v2.3.2`), so a semver input maps directly to that tag convention. A non-semver input (e.g. a branch name) is used as a raw ref, so new branches can be targeted directly too. + +Replaces today's "Validate version" step, and now runs **before** checkout (pure input parsing, no repo access needed): + +```powershell +$version = $env:INPUT_VERSION + +if ([string]::IsNullOrWhiteSpace($version)) +{ + $ref = "main" + $versionLabel = "latest" +} +elseif ($version -match '^\d+\.\d+\.\d+$') +{ + $ref = "v$version" + $versionLabel = $version +} +else +{ + $ref = $version + $versionLabel = ($version -replace '[\\/]', '-') +} +``` + +- Blank input (manual dispatch with nothing typed, or the `schedule` trigger, which has no `inputs` at all) → checkout `main`, label files `latest`. +- Semver input (`2.4.0`) → checkout tag `v2.4.0`, label files `2.4.0`. +- Anything else (`feature/foo`, `release/2.4`) → checkout that ref as-is, label files with `/` replaced by `-` (filesystem/artifact-name safety). + +**Limitation (accepted, forward-only):** this only works correctly for refs created *after* this workflow change merges, since it depends on code that doesn't exist in old tags/branches — the `GenerateOpenAPI` command name, the `adminApiMode` v2/v3 loop, and the `v3` API mode itself are all new. Checking out a pre-existing tag like `v1.4.3` would fail (no such command, no v3 concept at all). No compatibility shim is being built for historical refs; anyone needing an old spec regenerated would do so manually against that old tag's own tooling. + +### Steps (replacing everything from "Git create branch" onward in the current file) + +1. Resolve `ref` and `version` as above (no checkout needed yet). +2. Checkout `ODS-Admin-API` at the resolved `ref` (`actions/checkout` with `ref: ${{ steps.resolve-version.outputs.ref }}`). +3. Install Swashbuckle CLI. **Pin the version to `7.1.0`**, matching the `Swashbuckle.AspNetCore` package version this app actually references (`Directory.Packages.props`). The CLI loads the app's own assembly in-process to introspect routes, so its `Microsoft.OpenApi` dependency must match what the app was built against: `Swashbuckle.AspNetCore 7.1.0` uses `Microsoft.OpenApi 1.x`, while CLI `10.2.3` (the newest release) pulls in `Microsoft.OpenApi 2.x` and fails at runtime with a `FileNotFoundException` loading `Microsoft.OpenApi`. `.config/dotnet-tools.json`'s existing `swashbuckle.aspnetcore.cli` entry is bumped from `6.6.2` to `7.1.0` to match (its `"rollForward": false` setting still requires an exact-major .NET runtime match at `dotnet tool run` time — verified locally against .NET 10 via `dotnet exec --roll-forward Major`, which the CI runner's multi-SDK image resolves natively). **Remove** the "Install widdershins CLI" step. +4. Build and generate: `./build.ps1 -APIVersion -Configuration Release -DockerEnvValues $p -Command GenerateOpenAPI` — now produces both `admin-api-v2-.yaml` and `admin-api-v3-.yaml`. +5. **Remove**: "Git create branch", "Git add files", "Commit file" (`ghcommit-action`), and "Create PR" steps — none of them are needed anymore. +6. **Add**: `actions/upload-artifact` step uploading both generated yaml files (e.g. artifact name `admin-api-openapi-`, `retention-days` left at the org default unless a shorter/longer window is wanted later). + +### Permissions + +Drops from `contents: write` to `permissions: read-all` (no default) — the job no longer writes anything to the repository (no commits, no branches, no PRs). + +## Explicitly out of scope + +- Nothing is pushed, committed, or PR'd to `Ed-Fi-Alliance-OSS/Ed-Fi-API-Specifications` or to this repository. Downstream use of the generated specs (e.g., opening a PR in the spec repo) is a manual, human-driven step outside this workflow. +- No PAT or cross-repo credential is required. +- Markdown documentation generation (`widdershins`) is removed, not just skipped for this workflow — a developer wanting a markdown summary can still follow the manual procedure in `docs/yaml-to-md/yaml-to-md.md`. + +## Testing + +- `./build.ps1 -Command GenerateOpenAPI -APIVersion 2.4.0` locally produces both `admin-api-v2-2.4.0.yaml` and `admin-api-v3-2.4.0.yaml` under `docs/api-specifications/openapi-yaml/`, no markdown file is produced, and both files reflect only their own version's endpoints (e.g. the v2 file has no `/dataStores/manage` v3-only path and vice versa). +- Manually dispatch the workflow with an existing tag's version (e.g. an already-released `vX.Y.Z`) and confirm the run's Artifacts section contains both yaml files, generated from that tag's code. +- Manually dispatch the workflow with a branch name and confirm it checks out that branch rather than `main`. +- Manually dispatch the workflow with no version and confirm files are named with `latest` and the ref checked out is `main`. +- (Schedule trigger itself can't be tested on-demand; correctness of the cron expression and the `latest` fallback is verified by inspection plus the no-version manual-dispatch test above, which exercises the same code path.)