Skip to content

Commit 25b4abf

Browse files
authored
Merge pull request #1059 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 71a57df + 259770d commit 25b4abf

45 files changed

Lines changed: 3096 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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)