Skip to content

Commit 03ae94e

Browse files
committed
Add typed-response + operationId enrichment for openapi.json
The generated openapi.json types every request body but leaves each 200 response as the generic StandardResults envelope, and carries no operationId on any operation. That makes the spec hard to consume: OpenAPI importers that key on operationId skip every operation, and downstream tools get no typed output fields to map against. This adds a deterministic post-processing stage that enriches the generated spec without replacing the upstream generator: - operationId injection: bare endpoint name per operation, method-prefixed only where a single path carries multiple methods. Existing operationIds are preserved; collisions are a hard failure. - typed 200 responses: derived from two checked-in sources, the frontend shape baselines (Tests/Shapes/*.json) and page simpleColumns declarations. The { Results, Metadata } envelope is preserved as the API actually returns it. The enriched spec is published as the openapi.enriched.json asset on each GitHub Release. A PR check workflow runs the test suite and strictly lints the enriched spec against a committed ignore-baseline that pins pre-existing upstream findings, so any new finding fails CI. Pure function of the two repositories; same input produces byte-identical output. Stage and runner are PowerShell with a Pester suite (50 tests).
1 parent e8cd121 commit 03ae94e

8 files changed

Lines changed: 1107 additions & 0 deletions
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
#Requires -Version 7.0
2+
<#
3+
.SYNOPSIS
4+
Enriches a CIPP openapi.json with typed 200 response schemas derived by static
5+
analysis of the API and frontend repositories.
6+
7+
.DESCRIPTION
8+
The generated CIPP spec types every request body but leaves every 200 response
9+
as the generic StandardResults envelope. This stage fills typed per-endpoint
10+
response schemas for the read surface, using two deterministic sources that are
11+
already checked into the repositories (no live API calls):
12+
13+
1. Captured response shape baselines (CIPP/Tests/Shapes/*.json) - carry real
14+
field types and nesting. Preferred when present.
15+
2. Frontend table column declarations (simpleColumns in CIPP/src pages) -
16+
carry field names only. Used when no baseline exists; fields are typed as
17+
string and marked x-cipp-field-source: frontend so consumers know the type
18+
is a name-only inference, not a verified type.
19+
20+
Endpoints with neither source keep the StandardResults envelope, which is the
21+
correct shape for write/exec operations. Output is deterministic: the same input
22+
repositories always produce a byte-identical spec.
23+
24+
.PARAMETER InputSpec
25+
Path to the source openapi.json. Defaults to the repo-root spec relative to this
26+
script (.build/.. ).
27+
28+
.PARAMETER OutputSpec
29+
Path to write the enriched spec. Defaults to InputSpec (in-place rewrite).
30+
31+
.PARAMETER FrontendRepoPath
32+
Path to a checkout of the CIPP frontend repository. Provides both the shape
33+
baselines (Tests/Shapes) and the page column declarations (src).
34+
35+
.PARAMETER PassThru
36+
Return the enriched spec object instead of only writing it. Used by tests.
37+
38+
.EXAMPLE
39+
./Add-OpenApiResponseSchemas.ps1 -FrontendRepoPath ../CIPP
40+
41+
Rewrites the repo-root openapi.json in place with typed response schemas.
42+
#>
43+
[CmdletBinding()]
44+
param(
45+
[string]$InputSpec = (Join-Path $PSScriptRoot '..' 'openapi.json'),
46+
[string]$OutputSpec,
47+
[string]$FrontendRepoPath,
48+
[switch]$PassThru
49+
)
50+
51+
$ErrorActionPreference = 'Stop'
52+
53+
$script:CippHttpMethods = @('get', 'post', 'put', 'patch', 'delete')
54+
55+
function ConvertFrom-ShapeNode {
56+
<#
57+
.SYNOPSIS
58+
Converts one node of a captured shape tree into an OpenAPI schema fragment.
59+
#>
60+
param($Node)
61+
62+
if ($Node -is [string]) {
63+
switch ($Node) {
64+
'string' { return @{ type = 'string' } }
65+
'number' { return @{ type = 'number' } }
66+
'bool' { return @{ type = 'boolean' } }
67+
'datetime' { return [ordered]@{ type = 'string'; format = 'date-time' } }
68+
# 'null' (captured as null at sample time) and 'truncated' (below the
69+
# capture depth limit) carry no reliable type, so stay permissive.
70+
default { return @{} }
71+
}
72+
}
73+
74+
if ($Node -is [System.Collections.IDictionary]) {
75+
if ($Node['_type'] -eq 'array') {
76+
return [ordered]@{ type = 'array'; items = (ConvertFrom-ShapeNode -Node $Node['_element']) }
77+
}
78+
$properties = [ordered]@{}
79+
foreach ($key in ($Node.Keys | Sort-Object)) {
80+
$properties[[string]$key] = ConvertFrom-ShapeNode -Node $Node[$key]
81+
}
82+
return [ordered]@{ type = 'object'; properties = $properties }
83+
}
84+
85+
return @{}
86+
}
87+
88+
function Get-ShapeBaselineMap {
89+
<#
90+
.SYNOPSIS
91+
Maps endpoint name -> per-record OpenAPI schema, from captured shape baselines.
92+
.DESCRIPTION
93+
Reads only files carrying both _metadata and shape; the sibling
94+
test-results.json and any non-baseline file is skipped. The per-record schema
95+
is the baseline shape itself (the CIPP envelope's Results[] element).
96+
#>
97+
param([string]$ShapesDir)
98+
99+
$map = @{}
100+
if (-not (Test-Path $ShapesDir)) {
101+
Write-Warning "Shapes directory not found: $ShapesDir"
102+
return $map
103+
}
104+
105+
foreach ($file in (Get-ChildItem -Path $ShapesDir -Filter '*.json' | Sort-Object -Property FullName)) {
106+
$doc = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100
107+
if (-not ($doc -is [System.Collections.IDictionary] -and $doc.ContainsKey('_metadata') -and $doc.ContainsKey('shape'))) {
108+
continue
109+
}
110+
$endpoint = $doc['_metadata']['endpoint']
111+
if (-not $endpoint) { continue }
112+
$map[$endpoint] = ConvertFrom-ShapeNode -Node $doc['shape']
113+
}
114+
return $map
115+
}
116+
117+
function Get-FrontendColumnMap {
118+
<#
119+
.SYNOPSIS
120+
Maps endpoint name -> sorted unique field names, from page simpleColumns.
121+
.DESCRIPTION
122+
Intent: skips conditional simpleColumns arrays to avoid non-column branch strings; false negatives beat junk fields.
123+
Scans frontend page sources for files that pair an /api/<Endpoint> reference
124+
with a simpleColumns array, and unions the declared column names per endpoint.
125+
Field names are deterministic; their types are not, so callers type them as
126+
string with a provenance marker.
127+
#>
128+
param([string]$SrcDir)
129+
130+
$map = @{}
131+
if (-not (Test-Path $SrcDir)) {
132+
Write-Warning "Frontend src directory not found: $SrcDir"
133+
return $map
134+
}
135+
136+
$endpointPattern = [regex]'/api/([A-Za-z0-9_]+)'
137+
$columnsPattern = [regex]'(?s)\bsimpleColumns\s*(?:=|:)\s*(?:\{\s*)?\[(?<columns>[^\]]*)\]'
138+
$stringPattern = [regex]'"([^"]+)"|''([^'']+)'''
139+
140+
$files = Get-ChildItem -Path $SrcDir -Recurse -File -Include '*.js', '*.jsx'
141+
foreach ($file in $files) {
142+
$text = Get-Content -LiteralPath $file.FullName -Raw
143+
if ([string]::IsNullOrEmpty($text) -or $text -notmatch 'simpleColumns') { continue }
144+
145+
$endpoints = $endpointPattern.Matches($text) | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique
146+
if (-not $endpoints) { continue }
147+
148+
$columns = foreach ($colMatch in $columnsPattern.Matches($text)) {
149+
foreach ($strMatch in $stringPattern.Matches($colMatch.Groups['columns'].Value)) {
150+
$value = if ($strMatch.Groups[1].Success) { $strMatch.Groups[1].Value } else { $strMatch.Groups[2].Value }
151+
if ($value) { $value }
152+
}
153+
}
154+
if (-not $columns) { continue }
155+
156+
foreach ($endpoint in $endpoints) {
157+
if (-not $map.ContainsKey($endpoint)) { $map[$endpoint] = [System.Collections.Generic.HashSet[string]]::new() }
158+
foreach ($column in $columns) { [void]$map[$endpoint].Add($column) }
159+
}
160+
}
161+
return $map
162+
}
163+
164+
function ConvertTo-ColumnRecordSchema {
165+
<#
166+
.SYNOPSIS
167+
Builds a per-record object schema from a set of frontend column names.
168+
#>
169+
param([System.Collections.Generic.HashSet[string]]$Columns)
170+
171+
$properties = [ordered]@{}
172+
foreach ($column in ($Columns | Sort-Object)) {
173+
$properties[$column] = [ordered]@{ type = 'string'; 'x-cipp-field-source' = 'frontend' }
174+
}
175+
return [ordered]@{ type = 'object'; properties = $properties }
176+
}
177+
178+
function ConvertTo-ResponseEnvelopeSchema {
179+
<#
180+
.SYNOPSIS
181+
Wraps a per-record schema in the CIPP { Results: [...], Metadata: {...} } envelope.
182+
#>
183+
param($RecordSchema)
184+
185+
return [ordered]@{
186+
type = 'object'
187+
properties = [ordered]@{
188+
Results = [ordered]@{ type = 'array'; items = $RecordSchema }
189+
Metadata = [ordered]@{ type = 'object' }
190+
}
191+
}
192+
}
193+
194+
195+
function Get-CippOperationId {
196+
<#
197+
.SYNOPSIS
198+
Builds the deterministic operationId for one CIPP path and method.
199+
.DESCRIPTION
200+
Riftwing imports OpenAPI operations by operationId. CIPP upstream does not
201+
currently emit operationIds, so this keeps importer keys stable without
202+
depending on display labels or external data.
203+
#>
204+
param(
205+
[Parameter(Mandatory)][string]$Path,
206+
[Parameter(Mandatory)][string]$Method,
207+
[Parameter(Mandatory)][string[]]$PathMethods
208+
)
209+
210+
$endpointName = $Path -replace '^/api/', ''
211+
if ($PathMethods.Count -eq 1) {
212+
return $endpointName
213+
}
214+
215+
$methodName = [System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($Method.ToLowerInvariant())
216+
return "$methodName$endpointName"
217+
}
218+
219+
function Add-CippOperationId {
220+
<#
221+
.SYNOPSIS
222+
Injects missing operationIds and fails on duplicate operationIds.
223+
.DESCRIPTION
224+
Existing non-empty operationIds are preserved so this pass can retire itself
225+
when upstream starts emitting operationIds. Duplicate operationIds are fatal
226+
because importers commonly key operations by operationId.
227+
#>
228+
param([Parameter(Mandatory)][System.Collections.IDictionary]$Spec)
229+
230+
if (-not $Spec['paths']) { throw 'Spec has no paths.' }
231+
232+
$operationCount = 0
233+
$injectedCount = 0
234+
$operationIds = @{}
235+
236+
foreach ($pathEntry in $Spec['paths'].GetEnumerator()) {
237+
$pathMethods = @($pathEntry.Value.Keys | Where-Object { $_ -in $script:CippHttpMethods })
238+
foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) {
239+
if ($methodEntry.Key -notin $script:CippHttpMethods) { continue }
240+
241+
$operationCount++
242+
$operation = $methodEntry.Value
243+
$operationId = $operation['operationId']
244+
if ([string]::IsNullOrWhiteSpace([string]$operationId)) {
245+
$operationId = Get-CippOperationId -Path $pathEntry.Key -Method $methodEntry.Key -PathMethods $pathMethods
246+
$operation['operationId'] = $operationId
247+
$injectedCount++
248+
}
249+
250+
if ($operationIds.ContainsKey($operationId)) {
251+
throw "Duplicate operationId found: $operationId"
252+
}
253+
$operationIds[$operationId] = $true
254+
}
255+
}
256+
257+
return [pscustomobject]@{ Operations = $operationCount; Injected = $injectedCount; Unique = $operationIds.Count }
258+
}
259+
260+
function Resolve-SpecResponse {
261+
<#
262+
.SYNOPSIS
263+
Adds typed 200 response schemas to a parsed spec, in place, and returns counts.
264+
.DESCRIPTION
265+
The pure core of this stage: operates on an already-parsed spec hashtable and
266+
the two endpoint maps, with no file or repository access, so it is unit
267+
testable. Only existing 200 responses on get/post/put/patch/delete operations
268+
are touched; everything else (including operations with no matching source) is
269+
left exactly as found.
270+
#>
271+
param(
272+
[Parameter(Mandatory)][System.Collections.IDictionary]$Spec,
273+
[Parameter(Mandatory)][hashtable]$BaselineMap,
274+
[Parameter(Mandatory)][hashtable]$ColumnMap
275+
)
276+
277+
if (-not $Spec['paths']) { throw 'Spec has no paths.' }
278+
279+
$operationCount = 0
280+
$typedCount = 0
281+
282+
foreach ($pathEntry in $Spec['paths'].GetEnumerator()) {
283+
$endpoint = $pathEntry.Key -replace '^/api/', ''
284+
285+
$recordSchema = $null
286+
if ($BaselineMap.ContainsKey($endpoint)) {
287+
$recordSchema = $BaselineMap[$endpoint]
288+
} elseif ($ColumnMap.ContainsKey($endpoint)) {
289+
$recordSchema = ConvertTo-ColumnRecordSchema -Columns $ColumnMap[$endpoint]
290+
}
291+
292+
foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) {
293+
if ($methodEntry.Key -notin $script:CippHttpMethods) { continue }
294+
$operationCount++
295+
if ($null -eq $recordSchema) { continue }
296+
297+
$responses = $methodEntry.Value['responses']
298+
if ($null -eq $responses) { continue }
299+
300+
$okResponse = $responses['200']
301+
if (-not $okResponse) { continue }
302+
303+
$okResponse['content'] = [ordered]@{
304+
'application/json' = [ordered]@{ schema = (ConvertTo-ResponseEnvelopeSchema -RecordSchema $recordSchema) }
305+
}
306+
$typedCount++
307+
}
308+
}
309+
310+
return [pscustomobject]@{
311+
Operations = $operationCount
312+
Typed = $typedCount
313+
}
314+
}
315+
316+
function Add-CippResponseSchema {
317+
<#
318+
.SYNOPSIS
319+
File-level orchestration: read spec + repo sources, enrich, write output.
320+
#>
321+
param(
322+
[Parameter(Mandatory)][string]$InputSpec,
323+
[Parameter(Mandatory)][string]$OutputSpec,
324+
[Parameter(Mandatory)][string]$FrontendRepoPath,
325+
[switch]$PassThru
326+
)
327+
328+
if (-not (Test-Path $InputSpec)) { throw "Input spec not found: $InputSpec" }
329+
330+
$spec = Get-Content -LiteralPath $InputSpec -Raw | ConvertFrom-Json -AsHashtable -Depth 100
331+
$baselineMap = Get-ShapeBaselineMap -ShapesDir (Join-Path $FrontendRepoPath 'Tests' 'Shapes')
332+
$columnMap = Get-FrontendColumnMap -SrcDir (Join-Path $FrontendRepoPath 'src')
333+
334+
$operationIdResult = Add-CippOperationId -Spec $spec
335+
$result = Resolve-SpecResponse -Spec $spec -BaselineMap $baselineMap -ColumnMap $columnMap
336+
Write-Information "Operations: $($result.Operations) | typed responses added: $($result.Typed) | operationIds injected: $($operationIdResult.Injected) | unique operationIds: $($operationIdResult.Unique)" -InformationAction Continue
337+
338+
# Serialization is deterministic for the object this stage builds, but it does not globally canonicalize pre-existing spec keys.
339+
[System.IO.File]::WriteAllText($OutputSpec, ($spec | ConvertTo-Json -Depth 100))
340+
341+
if ($PassThru) { return $spec }
342+
}
343+
344+
# Run orchestration only when invoked as a script, not when dot-sourced for testing.
345+
if ($MyInvocation.InvocationName -ne '.') {
346+
if (-not $FrontendRepoPath) { throw 'FrontendRepoPath is required when running the script.' }
347+
if (-not $OutputSpec) { $OutputSpec = $InputSpec }
348+
Add-CippResponseSchema -InputSpec $InputSpec -OutputSpec $OutputSpec -FrontendRepoPath $FrontendRepoPath -PassThru:$PassThru
349+
}

.build/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# OpenAPI enrichment
2+
3+
`Add-OpenApiResponseSchemas.ps1` post-processes the generated CIPP `openapi.json`. It adds deterministic operationIds and typed `200` response schemas where response shape data can be derived from the CIPP frontend repository. It does not replace the upstream OpenAPI generator.
4+
5+
The enriched spec is published on each GitHub Release as the `openapi.enriched.json` release asset.
6+
7+
The PR check and release workflow strictly lint the CI-generated `openapi.enriched.json` with Redocly. The committed `.redocly.lint-ignore.yaml` baseline pins findings that already exist in the generated enriched spec because of upstream `openapi.json` issues. Any new Redocly error or warning that is not in the baseline fails CI.
8+
9+
To regenerate locally, check out the CIPP frontend repository and run:
10+
11+
```powershell
12+
pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 `
13+
-FrontendRepoPath <path-to-CIPP-frontend-checkout> `
14+
-InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json
15+
```
16+
17+
If upstream `openapi.json` legitimately changes and the pinned Redocly findings must be refreshed, regenerate the enriched spec first, then regenerate the ignore baseline from that enriched output:
18+
19+
```powershell
20+
pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 `
21+
-FrontendRepoPath <path-to-CIPP-frontend-checkout> `
22+
-InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json
23+
npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --generate-ignore-file
24+
```
25+
26+
Do not generate the baseline from the base `openapi.json`. The lint subject is always the generated `openapi.enriched.json`.
27+
28+
## Known limitations
29+
30+
- Only `get`, `post`, `put`, `patch`, and `delete` operations are processed. `head`, `options`, and `trace` are not present in the current spec.
31+
- Paths are assumed to start with `/api/`. All 580 current paths do.
32+
- When a typed `200` response is added, it replaces the existing `200.content`. Today that content is only the generic `StandardResults` envelope.
33+
- Conditional/ternary `simpleColumns` expressions are intentionally not parsed.
34+
35+
## Release workflow notes
36+
37+
- `openapi-enriched-release.yml` builds and uploads from the same tag. On `workflow_dispatch`, the `tag` input is checked out and used as the upload target. On `release: published`, the release tag is checked out and used as the upload target.
38+
- `.github/workflows/` is gitignored in this repository, so the OpenAPI workflow files require `git add -f` when they are intentionally added or updated.

0 commit comments

Comments
 (0)