Skip to content

Commit a3f1df4

Browse files
authored
Merge pull request #33 from oproto/feature/deterministic-openapi-specs
feat(openapi): add deterministic output and skip-unchanged features
2 parents 28aabca + b557216 commit a3f1df4

File tree

13 files changed

+3050
-18
lines changed

13 files changed

+3050
-18
lines changed
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
# Design Document: Deterministic Output
2+
3+
## Overview
4+
5+
This design ensures that OpenAPI specification outputs are deterministic across multiple runs. The key insight is that non-determinism in OpenAPI output typically comes from dictionary/collection iteration order, which varies based on insertion order or hash codes. By enforcing consistent ordering at serialization time, we guarantee identical output for identical input.
6+
7+
The design also adds a "skip unchanged" feature to the merge tool, preventing unnecessary file writes when content hasn't changed.
8+
9+
## Architecture
10+
11+
The solution involves two main areas:
12+
13+
1. **Ordering Enforcement**: Modify the serialization/output phase to sort collections before writing
14+
2. **Skip Unchanged**: Add file comparison logic to the merge tool CLI
15+
16+
### Ordering Strategy
17+
18+
Rather than modifying every place where collections are built, we'll implement ordering at the final output stage:
19+
20+
1. **Source Generator**: Sort collections in `MergeOpenApiDocs` before serialization
21+
2. **Merger**: Sort collections in the `Merge` method before returning the result
22+
3. **Merge Tool**: Sort collections before writing (as a safety net)
23+
24+
This approach minimizes code changes and ensures ordering regardless of how documents are constructed.
25+
26+
### Ordering Rules
27+
28+
| Collection | Ordering Rule |
29+
|------------|---------------|
30+
| Paths | Alphabetical by path string |
31+
| Schemas | Alphabetical by schema name |
32+
| Properties (within schemas) | Alphabetical by property name |
33+
| Tags | Alphabetical by tag name |
34+
| Tag Groups | Alphabetical by group name |
35+
| Tags within Tag Groups | Alphabetical by tag name |
36+
| Security Schemes | Alphabetical by scheme name |
37+
| Operations (within paths) | HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE |
38+
| Responses | Ascending by status code (numeric) |
39+
| Examples | Alphabetical by example name |
40+
| Servers | Preserve declaration/configuration order |
41+
42+
## Components and Interfaces
43+
44+
### New Component: OpenApiDocumentSorter
45+
46+
A static utility class that sorts all collections in an OpenAPI document for deterministic output.
47+
48+
```csharp
49+
namespace Oproto.Lambda.OpenApi.Merge;
50+
51+
/// <summary>
52+
/// Sorts OpenAPI document collections for deterministic output.
53+
/// </summary>
54+
public static class OpenApiDocumentSorter
55+
{
56+
/// <summary>
57+
/// HTTP method ordering for operations within a path.
58+
/// </summary>
59+
private static readonly OperationType[] OperationOrder =
60+
{
61+
OperationType.Get,
62+
OperationType.Put,
63+
OperationType.Post,
64+
OperationType.Delete,
65+
OperationType.Options,
66+
OperationType.Head,
67+
OperationType.Patch,
68+
OperationType.Trace
69+
};
70+
71+
/// <summary>
72+
/// Sorts all collections in the document for deterministic output.
73+
/// Returns a new document with sorted collections.
74+
/// </summary>
75+
public static OpenApiDocument Sort(OpenApiDocument document);
76+
77+
/// <summary>
78+
/// Sorts paths alphabetically.
79+
/// </summary>
80+
internal static OpenApiPaths SortPaths(OpenApiPaths paths);
81+
82+
/// <summary>
83+
/// Sorts schemas alphabetically by name.
84+
/// </summary>
85+
internal static IDictionary<string, OpenApiSchema> SortSchemas(
86+
IDictionary<string, OpenApiSchema> schemas);
87+
88+
/// <summary>
89+
/// Sorts properties within a schema alphabetically.
90+
/// </summary>
91+
internal static OpenApiSchema SortSchemaProperties(OpenApiSchema schema);
92+
93+
/// <summary>
94+
/// Sorts tags alphabetically by name.
95+
/// </summary>
96+
internal static IList<OpenApiTag> SortTags(IList<OpenApiTag> tags);
97+
98+
/// <summary>
99+
/// Sorts security schemes alphabetically by name.
100+
/// </summary>
101+
internal static IDictionary<string, OpenApiSecurityScheme> SortSecuritySchemes(
102+
IDictionary<string, OpenApiSecurityScheme> schemes);
103+
104+
/// <summary>
105+
/// Sorts operations within a path by HTTP method order.
106+
/// </summary>
107+
internal static IDictionary<OperationType, OpenApiOperation> SortOperations(
108+
IDictionary<OperationType, OpenApiOperation> operations);
109+
110+
/// <summary>
111+
/// Sorts responses by status code (ascending).
112+
/// </summary>
113+
internal static OpenApiResponses SortResponses(OpenApiResponses responses);
114+
115+
/// <summary>
116+
/// Sorts examples alphabetically by name.
117+
/// </summary>
118+
internal static IDictionary<string, OpenApiExample> SortExamples(
119+
IDictionary<string, OpenApiExample> examples);
120+
121+
/// <summary>
122+
/// Sorts tag groups alphabetically by name, and tags within groups alphabetically.
123+
/// </summary>
124+
internal static void SortTagGroups(OpenApiDocument document);
125+
}
126+
```
127+
128+
### Modified Component: OpenApiMerger
129+
130+
The `Merge` method will call `OpenApiDocumentSorter.Sort()` before returning the result.
131+
132+
```csharp
133+
public MergeResult Merge(
134+
MergeConfiguration config,
135+
IEnumerable<(SourceConfiguration Source, OpenApiDocument Document)> documents)
136+
{
137+
// ... existing merge logic ...
138+
139+
// Sort for deterministic output
140+
var sortedDocument = OpenApiDocumentSorter.Sort(mergedDocument);
141+
142+
return new MergeResult(sortedDocument, warnings, success: true);
143+
}
144+
```
145+
146+
### Modified Component: MergeCommand
147+
148+
Add `--force` flag and skip-unchanged logic.
149+
150+
```csharp
151+
// New option
152+
var forceOption = new Option<bool>(
153+
new[] { "-f", "--force" },
154+
"Force write output even if unchanged");
155+
156+
// Modified write logic
157+
private static async Task<bool> WriteOpenApiDocumentAsync(
158+
OpenApiDocument document,
159+
string outputPath,
160+
bool verbose,
161+
bool force)
162+
{
163+
// ... validation ...
164+
165+
var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0);
166+
167+
// Check if file exists and content matches
168+
if (!force && File.Exists(outputPath))
169+
{
170+
var existingContent = await File.ReadAllTextAsync(outputPath);
171+
if (existingContent == json)
172+
{
173+
if (verbose)
174+
{
175+
Console.WriteLine($"Output unchanged, skipping write: {outputPath}");
176+
}
177+
return false; // Indicates file was not written
178+
}
179+
}
180+
181+
await File.WriteAllTextAsync(outputPath, json);
182+
return true; // Indicates file was written
183+
}
184+
```
185+
186+
### Modified Component: OpenApiSpecGenerator (Source Generator)
187+
188+
The source generator will sort the merged document before serialization.
189+
190+
```csharp
191+
private OpenApiDocument MergeOpenApiDocs(ImmutableArray<OpenApiDocument?> docs, Compilation? compilation)
192+
{
193+
// ... existing merge logic ...
194+
195+
// Sort for deterministic output before returning
196+
return SortDocument(mergedDoc);
197+
}
198+
199+
/// <summary>
200+
/// Sorts all collections in the document for deterministic output.
201+
/// </summary>
202+
private static OpenApiDocument SortDocument(OpenApiDocument document)
203+
{
204+
// Sort paths
205+
if (document.Paths != null && document.Paths.Count > 0)
206+
{
207+
var sortedPaths = new OpenApiPaths();
208+
foreach (var path in document.Paths.OrderBy(p => p.Key, StringComparer.Ordinal))
209+
{
210+
sortedPaths[path.Key] = SortPathItem(path.Value);
211+
}
212+
document.Paths = sortedPaths;
213+
}
214+
215+
// Sort schemas
216+
if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0)
217+
{
218+
var sortedSchemas = new Dictionary<string, OpenApiSchema>();
219+
foreach (var schema in document.Components.Schemas.OrderBy(s => s.Key, StringComparer.Ordinal))
220+
{
221+
sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value);
222+
}
223+
document.Components.Schemas = sortedSchemas;
224+
}
225+
226+
// Sort tags
227+
if (document.Tags != null && document.Tags.Count > 0)
228+
{
229+
document.Tags = document.Tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList();
230+
}
231+
232+
// Sort security schemes
233+
if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0)
234+
{
235+
var sortedSchemes = new Dictionary<string, OpenApiSecurityScheme>();
236+
foreach (var scheme in document.Components.SecuritySchemes.OrderBy(s => s.Key, StringComparer.Ordinal))
237+
{
238+
sortedSchemes[scheme.Key] = scheme.Value;
239+
}
240+
document.Components.SecuritySchemes = sortedSchemes;
241+
}
242+
243+
// Sort tag groups
244+
SortTagGroups(document);
245+
246+
return document;
247+
}
248+
```
249+
250+
## Data Models
251+
252+
No new data models are required. The existing OpenAPI models from Microsoft.OpenApi are used.
253+
254+
## Correctness Properties
255+
256+
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
257+
258+
### Property 1: Path Ordering
259+
260+
*For any* OpenAPI document with multiple paths, after sorting, the paths SHALL appear in alphabetical order by path string using ordinal comparison.
261+
262+
**Validates: Requirements 1.1, 1.2**
263+
264+
### Property 2: Schema Ordering
265+
266+
*For any* OpenAPI document with multiple schemas, after sorting, the schemas SHALL appear in alphabetical order by schema name using ordinal comparison.
267+
268+
**Validates: Requirements 2.1, 2.2**
269+
270+
### Property 3: Property Ordering Within Schemas
271+
272+
*For any* OpenAPI schema with multiple properties, after sorting, the properties SHALL appear in alphabetical order by property name using ordinal comparison.
273+
274+
**Validates: Requirements 3.1, 3.2**
275+
276+
### Property 4: Tag Ordering
277+
278+
*For any* OpenAPI document with multiple tags, after sorting, the tags SHALL appear in alphabetical order by tag name using ordinal comparison.
279+
280+
**Validates: Requirements 4.1, 4.2**
281+
282+
### Property 5: Tag Group Ordering
283+
284+
*For any* OpenAPI document with tag groups, after sorting, the tag groups SHALL appear in alphabetical order by group name, and tags within each group SHALL appear in alphabetical order.
285+
286+
**Validates: Requirements 5.1, 5.2, 5.3**
287+
288+
### Property 6: Security Scheme Ordering
289+
290+
*For any* OpenAPI document with multiple security schemes, after sorting, the security schemes SHALL appear in alphabetical order by scheme name using ordinal comparison.
291+
292+
**Validates: Requirements 7.1, 7.2**
293+
294+
### Property 7: Operation Ordering
295+
296+
*For any* path with multiple operations, after sorting, the operations SHALL appear in HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
297+
298+
**Validates: Requirements 8.1, 8.2**
299+
300+
### Property 8: Response Ordering
301+
302+
*For any* operation with multiple responses, after sorting, the responses SHALL appear in ascending order by status code (treating status codes as integers, with "default" sorted last).
303+
304+
**Validates: Requirements 9.1, 9.2**
305+
306+
### Property 9: Example Ordering
307+
308+
*For any* media type with multiple examples, after sorting, the examples SHALL appear in alphabetical order by example name using ordinal comparison.
309+
310+
**Validates: Requirements 11.1, 11.2**
311+
312+
### Property 10: Output Idempotence (Round-Trip)
313+
314+
*For any* valid OpenAPI document, serializing the document, then deserializing and re-serializing SHALL produce identical JSON output.
315+
316+
**Validates: Requirements 1.3, 2.3, 3.3, 4.3**
317+
318+
### Property 11: Server Order Preservation
319+
320+
*For any* OpenAPI document with servers, the servers SHALL appear in the same order as they were declared in source code or configuration.
321+
322+
**Validates: Requirements 6.1, 6.2**
323+
324+
## Error Handling
325+
326+
| Scenario | Handling |
327+
|----------|----------|
328+
| Null document passed to sorter | Return null or throw ArgumentNullException |
329+
| Empty collections | Return empty sorted collection (no-op) |
330+
| File read error during skip-unchanged check | Log warning and proceed with write |
331+
| File write permission denied | Propagate exception with clear message |
332+
333+
## Testing Strategy
334+
335+
### Property-Based Testing
336+
337+
We will use FsCheck for property-based testing, consistent with the existing test suite. Each correctness property will be implemented as a property-based test with minimum 100 iterations.
338+
339+
**Test Configuration:**
340+
- Framework: xUnit with FsCheck.Xunit
341+
- Minimum iterations: 100 per property
342+
- Generators: Custom generators for OpenAPI documents with various collection sizes
343+
344+
### Unit Tests
345+
346+
Unit tests will cover:
347+
- Edge cases (empty collections, single items, null values)
348+
- Skip-unchanged file comparison logic
349+
- Force flag behavior
350+
- Specific ordering edge cases (e.g., "default" response sorting)
351+
352+
### Test File Organization
353+
354+
```
355+
Oproto.Lambda.OpenApi.Merge.Tests/
356+
DeterministicOutputPropertyTests.cs # Property tests for sorter
357+
OpenApiDocumentSorterTests.cs # Unit tests for sorter
358+
359+
Oproto.Lambda.OpenApi.Tests/
360+
DeterministicGeneratorPropertyTests.cs # Property tests for source generator ordering
361+
```

0 commit comments

Comments
 (0)