Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6ee0fbe
Add design doc for OpenAPI v2/v3 artifact-publishing workflow
jagudelo-gap Jul 30, 2026
fd130f5
Add implementation plan for OpenAPI v2/v3 artifact-publishing workflow
jagudelo-gap Jul 30, 2026
c29e245
Revise OpenAPI workflow design/plan: adminApiMode switching, CLI vers…
jagudelo-gap Jul 31, 2026
7b22803
Ignore .superpowers/ subagent-driven-development workspace directory
jagudelo-gap Jul 31, 2026
a7201fc
Generate v2 and v3 OpenAPI specs via adminApiMode switching, remove w…
jagudelo-gap Jul 31, 2026
8d2b019
Fix stale GenerateOpenAPIAndMD reference in build.ps1 header comment
jagudelo-gap Jul 31, 2026
30b0377
Publish OpenAPI v2/v3 specs as workflow artifacts from a resolved ref…
jagudelo-gap Jul 31, 2026
1057171
Fix inaccurate version comment on upload-artifact SHA pin
jagudelo-gap Jul 31, 2026
9ba692b
Merge remote-tracking branch 'origin/main' into ADMINAPI-1435
jagudelo-gap Aug 3, 2026
6f89a79
Update Swashbuckle CLI version to 7.1.0 in workflow and configuration…
jagudelo-gap Aug 3, 2026
b3b961a
Implement two-pass OpenAPI spec generation with adminApiMode switchin…
jagudelo-gap Aug 3, 2026
ce8d217
Add response options for tenant data store and ODS instance endpoints
jagudelo-gap Aug 3, 2026
e426061
Add support for custom Location header descriptions in 201 responses
jagudelo-gap Aug 3, 2026
c3d3361
Add operation filters for anonymous access and problem details respon…
jagudelo-gap Aug 3, 2026
3523a12
Update scope for Admin API access in WebApplicationBuilderExtensions
jagudelo-gap Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"rollForward": false
},
"swashbuckle.aspnetcore.cli": {
"version": "6.6.2",
"version": "7.1.0",
"commands": [
"swagger"
],
Expand Down
88 changes: 39 additions & 49 deletions .github/workflows/openapi-md.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,6 @@ coveragereport/

# Cache files
*.lscache

# Superpowers subagent-driven-development workspace
.superpowers/
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(this RouteHandlerBuilder builder, int code, string? description = null)
public static RouteHandlerBuilder WithResponse<T>(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));
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
public class LocationHeaderDescriptionMetadata(string description)
{
public string Description { get; } = description;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<JobQueuedResult>(201, locationDescription: "URI of the queued job's status endpoint."))
.BuildForVersions(AdminApiVersions.V3);

AdminApiEndpointBuilder
Expand All @@ -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<JobQueuedResult>(201, locationDescription: "URI of the queued job's status endpoint.")
.WithResponseCode(404))
.BuildForVersions(AdminApiVersions.V3);
}
Expand Down Expand Up @@ -64,10 +70,10 @@ public static async Task<IResult> 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}";

Expand Down Expand Up @@ -106,10 +112,10 @@ public static async Task<IResult> 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}";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
AdminApiEndpointBuilder
.MapGet(endpoints, "/tenants/{tenantName}/dataStores/edOrgs", GetTenantEdOrgsByDataStoresAsync)
.WithRouteOptions(b => b.WithResponse<TenantDetailsResponse>(200))
.BuildForVersions(AdminApiVersions.V3);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
AdminApiEndpointBuilder
.MapGet(endpoints, "/tenants/{tenantName}/odsInstances/edOrgs", GetTenantEdOrgsByInstancesAsync)
.WithRouteOptions(b => b.WithResponse<TenantDetailsResponse>(200))
.BuildForVersions(AdminApiVersions.V2);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
public class AnonymousOperationSecurityFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.ApiDescription.ActionDescriptor.EndpointMetadata.OfType<IAllowAnonymous>().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<OpenApiSecurityRequirement> { new OpenApiSecurityRequirement() };
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="LocationHeaderDescriptionMetadata"/>.
/// </summary>
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<LocationHeaderDescriptionMetadata>()
.FirstOrDefault()
?.Description;

response.Headers ??= new Dictionary<string, OpenApiHeader>();
response.Headers["Location"] = new OpenApiHeader
{
Description = descriptionOverride ?? DefaultDescription,
Schema = new OpenApiSchema { Type = "string", Format = "uri" }
};
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Documents the "application/problem+json" <see cref="ProblemDetails"/> 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.
/// </summary>
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<string, OpenApiMediaType>
{
[ProblemJsonContentType] = new OpenApiMediaType { Schema = schema }
};
}
}

private static bool IsErrorStatusCode(string statusCode) =>
statusCode.Length == 3 && statusCode[0] is '4' or '5';
}
Loading
Loading