|
| 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