diff --git a/.kiro/specs/deterministic-output/design.md b/.kiro/specs/deterministic-output/design.md new file mode 100644 index 0000000..1c00791 --- /dev/null +++ b/.kiro/specs/deterministic-output/design.md @@ -0,0 +1,361 @@ +# Design Document: Deterministic Output + +## Overview + +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. + +The design also adds a "skip unchanged" feature to the merge tool, preventing unnecessary file writes when content hasn't changed. + +## Architecture + +The solution involves two main areas: + +1. **Ordering Enforcement**: Modify the serialization/output phase to sort collections before writing +2. **Skip Unchanged**: Add file comparison logic to the merge tool CLI + +### Ordering Strategy + +Rather than modifying every place where collections are built, we'll implement ordering at the final output stage: + +1. **Source Generator**: Sort collections in `MergeOpenApiDocs` before serialization +2. **Merger**: Sort collections in the `Merge` method before returning the result +3. **Merge Tool**: Sort collections before writing (as a safety net) + +This approach minimizes code changes and ensures ordering regardless of how documents are constructed. + +### Ordering Rules + +| Collection | Ordering Rule | +|------------|---------------| +| Paths | Alphabetical by path string | +| Schemas | Alphabetical by schema name | +| Properties (within schemas) | Alphabetical by property name | +| Tags | Alphabetical by tag name | +| Tag Groups | Alphabetical by group name | +| Tags within Tag Groups | Alphabetical by tag name | +| Security Schemes | Alphabetical by scheme name | +| Operations (within paths) | HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE | +| Responses | Ascending by status code (numeric) | +| Examples | Alphabetical by example name | +| Servers | Preserve declaration/configuration order | + +## Components and Interfaces + +### New Component: OpenApiDocumentSorter + +A static utility class that sorts all collections in an OpenAPI document for deterministic output. + +```csharp +namespace Oproto.Lambda.OpenApi.Merge; + +/// +/// Sorts OpenAPI document collections for deterministic output. +/// +public static class OpenApiDocumentSorter +{ + /// + /// HTTP method ordering for operations within a path. + /// + private static readonly OperationType[] OperationOrder = + { + OperationType.Get, + OperationType.Put, + OperationType.Post, + OperationType.Delete, + OperationType.Options, + OperationType.Head, + OperationType.Patch, + OperationType.Trace + }; + + /// + /// Sorts all collections in the document for deterministic output. + /// Returns a new document with sorted collections. + /// + public static OpenApiDocument Sort(OpenApiDocument document); + + /// + /// Sorts paths alphabetically. + /// + internal static OpenApiPaths SortPaths(OpenApiPaths paths); + + /// + /// Sorts schemas alphabetically by name. + /// + internal static IDictionary SortSchemas( + IDictionary schemas); + + /// + /// Sorts properties within a schema alphabetically. + /// + internal static OpenApiSchema SortSchemaProperties(OpenApiSchema schema); + + /// + /// Sorts tags alphabetically by name. + /// + internal static IList SortTags(IList tags); + + /// + /// Sorts security schemes alphabetically by name. + /// + internal static IDictionary SortSecuritySchemes( + IDictionary schemes); + + /// + /// Sorts operations within a path by HTTP method order. + /// + internal static IDictionary SortOperations( + IDictionary operations); + + /// + /// Sorts responses by status code (ascending). + /// + internal static OpenApiResponses SortResponses(OpenApiResponses responses); + + /// + /// Sorts examples alphabetically by name. + /// + internal static IDictionary SortExamples( + IDictionary examples); + + /// + /// Sorts tag groups alphabetically by name, and tags within groups alphabetically. + /// + internal static void SortTagGroups(OpenApiDocument document); +} +``` + +### Modified Component: OpenApiMerger + +The `Merge` method will call `OpenApiDocumentSorter.Sort()` before returning the result. + +```csharp +public MergeResult Merge( + MergeConfiguration config, + IEnumerable<(SourceConfiguration Source, OpenApiDocument Document)> documents) +{ + // ... existing merge logic ... + + // Sort for deterministic output + var sortedDocument = OpenApiDocumentSorter.Sort(mergedDocument); + + return new MergeResult(sortedDocument, warnings, success: true); +} +``` + +### Modified Component: MergeCommand + +Add `--force` flag and skip-unchanged logic. + +```csharp +// New option +var forceOption = new Option( + new[] { "-f", "--force" }, + "Force write output even if unchanged"); + +// Modified write logic +private static async Task WriteOpenApiDocumentAsync( + OpenApiDocument document, + string outputPath, + bool verbose, + bool force) +{ + // ... validation ... + + var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + + // Check if file exists and content matches + if (!force && File.Exists(outputPath)) + { + var existingContent = await File.ReadAllTextAsync(outputPath); + if (existingContent == json) + { + if (verbose) + { + Console.WriteLine($"Output unchanged, skipping write: {outputPath}"); + } + return false; // Indicates file was not written + } + } + + await File.WriteAllTextAsync(outputPath, json); + return true; // Indicates file was written +} +``` + +### Modified Component: OpenApiSpecGenerator (Source Generator) + +The source generator will sort the merged document before serialization. + +```csharp +private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs, Compilation? compilation) +{ + // ... existing merge logic ... + + // Sort for deterministic output before returning + return SortDocument(mergedDoc); +} + +/// +/// Sorts all collections in the document for deterministic output. +/// +private static OpenApiDocument SortDocument(OpenApiDocument document) +{ + // Sort paths + if (document.Paths != null && document.Paths.Count > 0) + { + var sortedPaths = new OpenApiPaths(); + foreach (var path in document.Paths.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + sortedPaths[path.Key] = SortPathItem(path.Value); + } + document.Paths = sortedPaths; + } + + // Sort schemas + if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0) + { + var sortedSchemas = new Dictionary(); + foreach (var schema in document.Components.Schemas.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value); + } + document.Components.Schemas = sortedSchemas; + } + + // Sort tags + if (document.Tags != null && document.Tags.Count > 0) + { + document.Tags = document.Tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); + } + + // Sort security schemes + if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0) + { + var sortedSchemes = new Dictionary(); + foreach (var scheme in document.Components.SecuritySchemes.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemes[scheme.Key] = scheme.Value; + } + document.Components.SecuritySchemes = sortedSchemes; + } + + // Sort tag groups + SortTagGroups(document); + + return document; +} +``` + +## Data Models + +No new data models are required. The existing OpenAPI models from Microsoft.OpenApi are used. + +## Correctness Properties + +*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.* + +### Property 1: Path Ordering + +*For any* OpenAPI document with multiple paths, after sorting, the paths SHALL appear in alphabetical order by path string using ordinal comparison. + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Schema Ordering + +*For any* OpenAPI document with multiple schemas, after sorting, the schemas SHALL appear in alphabetical order by schema name using ordinal comparison. + +**Validates: Requirements 2.1, 2.2** + +### Property 3: Property Ordering Within Schemas + +*For any* OpenAPI schema with multiple properties, after sorting, the properties SHALL appear in alphabetical order by property name using ordinal comparison. + +**Validates: Requirements 3.1, 3.2** + +### Property 4: Tag Ordering + +*For any* OpenAPI document with multiple tags, after sorting, the tags SHALL appear in alphabetical order by tag name using ordinal comparison. + +**Validates: Requirements 4.1, 4.2** + +### Property 5: Tag Group Ordering + +*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. + +**Validates: Requirements 5.1, 5.2, 5.3** + +### Property 6: Security Scheme Ordering + +*For any* OpenAPI document with multiple security schemes, after sorting, the security schemes SHALL appear in alphabetical order by scheme name using ordinal comparison. + +**Validates: Requirements 7.1, 7.2** + +### Property 7: Operation Ordering + +*For any* path with multiple operations, after sorting, the operations SHALL appear in HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE. + +**Validates: Requirements 8.1, 8.2** + +### Property 8: Response Ordering + +*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). + +**Validates: Requirements 9.1, 9.2** + +### Property 9: Example Ordering + +*For any* media type with multiple examples, after sorting, the examples SHALL appear in alphabetical order by example name using ordinal comparison. + +**Validates: Requirements 11.1, 11.2** + +### Property 10: Output Idempotence (Round-Trip) + +*For any* valid OpenAPI document, serializing the document, then deserializing and re-serializing SHALL produce identical JSON output. + +**Validates: Requirements 1.3, 2.3, 3.3, 4.3** + +### Property 11: Server Order Preservation + +*For any* OpenAPI document with servers, the servers SHALL appear in the same order as they were declared in source code or configuration. + +**Validates: Requirements 6.1, 6.2** + +## Error Handling + +| Scenario | Handling | +|----------|----------| +| Null document passed to sorter | Return null or throw ArgumentNullException | +| Empty collections | Return empty sorted collection (no-op) | +| File read error during skip-unchanged check | Log warning and proceed with write | +| File write permission denied | Propagate exception with clear message | + +## Testing Strategy + +### Property-Based Testing + +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. + +**Test Configuration:** +- Framework: xUnit with FsCheck.Xunit +- Minimum iterations: 100 per property +- Generators: Custom generators for OpenAPI documents with various collection sizes + +### Unit Tests + +Unit tests will cover: +- Edge cases (empty collections, single items, null values) +- Skip-unchanged file comparison logic +- Force flag behavior +- Specific ordering edge cases (e.g., "default" response sorting) + +### Test File Organization + +``` +Oproto.Lambda.OpenApi.Merge.Tests/ + DeterministicOutputPropertyTests.cs # Property tests for sorter + OpenApiDocumentSorterTests.cs # Unit tests for sorter + +Oproto.Lambda.OpenApi.Tests/ + DeterministicGeneratorPropertyTests.cs # Property tests for source generator ordering +``` diff --git a/.kiro/specs/deterministic-output/requirements.md b/.kiro/specs/deterministic-output/requirements.md new file mode 100644 index 0000000..1ec6d09 --- /dev/null +++ b/.kiro/specs/deterministic-output/requirements.md @@ -0,0 +1,124 @@ +# Requirements Document + +## Introduction + +This feature ensures that OpenAPI specification outputs are deterministic - meaning that if no changes are made to the source APIs, the generated OpenAPI JSON files will always be identical. This is critical for CI/CD pipelines, version control, and avoiding unnecessary file changes. Additionally, this feature adds the ability to skip writing output files when they match existing files, with an optional override flag. + +## Glossary + +- **Source_Generator**: The Roslyn-based source generator (`OpenApiSpecGenerator`) that produces OpenAPI specifications from Lambda function attributes at compile time. +- **Merger**: The `OpenApiMerger` component that combines multiple OpenAPI documents into a single unified specification. +- **Deterministic_Output**: Output that is identical across multiple runs when given the same input, regardless of execution order or timing. +- **Schema_Ordering**: The order in which schemas appear in the `components/schemas` section of an OpenAPI document. +- **Path_Ordering**: The order in which paths appear in the `paths` section of an OpenAPI document. +- **Tag_Ordering**: The order in which tags appear in the `tags` section of an OpenAPI document. +- **Property_Ordering**: The order in which properties appear within a schema definition. +- **Skip_Unchanged**: A feature that prevents writing output files when the new content matches the existing file content. + +## Requirements + +### Requirement 1: Deterministic Path Ordering + +**User Story:** As a developer, I want paths in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes Lambda functions, THE Source_Generator SHALL output paths in alphabetical order by path string. +2. WHEN the Merger combines multiple OpenAPI documents, THE Merger SHALL output paths in alphabetical order by path string. +3. FOR ALL valid OpenAPI documents, generating then re-generating without changes SHALL produce identical path ordering. + +### Requirement 2: Deterministic Schema Ordering + +**User Story:** As a developer, I want schemas in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes types, THE Source_Generator SHALL output schemas in alphabetical order by schema name. +2. WHEN the Merger combines schemas from multiple sources, THE Merger SHALL output schemas in alphabetical order by schema name. +3. FOR ALL valid OpenAPI documents with schemas, generating then re-generating without changes SHALL produce identical schema ordering. + +### Requirement 3: Deterministic Property Ordering Within Schemas + +**User Story:** As a developer, I want properties within schemas to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator generates schema properties, THE Source_Generator SHALL output properties in alphabetical order by property name. +2. WHEN the Merger clones schemas, THE Merger SHALL preserve or establish alphabetical ordering of properties. +3. FOR ALL valid schemas with properties, generating then re-generating without changes SHALL produce identical property ordering. + +### Requirement 4: Deterministic Tag Ordering + +**User Story:** As a developer, I want tags in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator collects tags from operations, THE Source_Generator SHALL output tags in alphabetical order by tag name. +2. WHEN the Merger combines tags from multiple sources, THE Merger SHALL output tags in alphabetical order by tag name. +3. FOR ALL valid OpenAPI documents with tags, generating then re-generating without changes SHALL produce identical tag ordering. + +### Requirement 5: Deterministic Tag Group Ordering + +**User Story:** As a developer, I want tag groups in the x-tagGroups extension to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes OpenApiTagGroup attributes, THE Source_Generator SHALL output tag groups in alphabetical order by group name. +2. WHEN the Merger combines tag groups from multiple sources, THE Merger SHALL output tag groups in alphabetical order by group name. +3. WHEN tag groups contain tags, THE Merger SHALL output tags within each group in alphabetical order. + +### Requirement 6: Deterministic Server Ordering + +**User Story:** As a developer, I want servers in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes OpenApiServer attributes, THE Source_Generator SHALL output servers in the order they are declared in source code. +2. WHEN the Merger combines servers from configuration, THE Merger SHALL output servers in the order they appear in the configuration. + +### Requirement 7: Deterministic Security Scheme Ordering + +**User Story:** As a developer, I want security schemes in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator processes security schemes, THE Source_Generator SHALL output security schemes in alphabetical order by scheme name. +2. WHEN the Merger combines security schemes from multiple sources, THE Merger SHALL output security schemes in alphabetical order by scheme name. + +### Requirement 8: Deterministic Operation Ordering Within Paths + +**User Story:** As a developer, I want operations within each path to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator generates operations for a path, THE Source_Generator SHALL output operations in a consistent order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE). +2. WHEN the Merger combines operations for a path, THE Merger SHALL output operations in the same consistent order. + +### Requirement 9: Deterministic Response Ordering + +**User Story:** As a developer, I want responses within operations to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator generates responses for an operation, THE Source_Generator SHALL output responses in ascending order by status code. +2. WHEN the Merger clones responses, THE Merger SHALL preserve or establish ascending order by status code. + +### Requirement 10: Skip Unchanged Output Files + +**User Story:** As a developer, I want the build process to skip writing output files when they haven't changed, so that I don't get unnecessary file modifications in version control. + +#### Acceptance Criteria + +1. WHEN the Merge_Tool generates output and an existing file matches the new content, THE Merge_Tool SHALL skip writing the file by default. +2. WHEN the Merge_Tool skips writing a file, THE Merge_Tool SHALL log a message indicating the file was unchanged. +3. WHEN the --force flag is provided, THE Merge_Tool SHALL write the output file regardless of whether it matches existing content. +4. IF the output file does not exist, THEN THE Merge_Tool SHALL write the file regardless of any flags. + +### Requirement 11: Deterministic Example Ordering + +**User Story:** As a developer, I want examples in the generated OpenAPI specification to always appear in the same order, so that version control diffs only show actual changes. + +#### Acceptance Criteria + +1. WHEN the Source_Generator generates examples for a media type, THE Source_Generator SHALL output examples in alphabetical order by example name. +2. WHEN the Merger clones examples, THE Merger SHALL preserve or establish alphabetical ordering of examples. diff --git a/.kiro/specs/deterministic-output/tasks.md b/.kiro/specs/deterministic-output/tasks.md new file mode 100644 index 0000000..600dad0 --- /dev/null +++ b/.kiro/specs/deterministic-output/tasks.md @@ -0,0 +1,130 @@ +# Implementation Plan: Deterministic Output + +## Overview + +This implementation adds deterministic ordering to OpenAPI output and a skip-unchanged feature to the merge tool. The approach is to create a central sorting utility, integrate it into both the merger and source generator, and add file comparison logic to the CLI. + +## Tasks + +- [x] 1. Create OpenApiDocumentSorter utility class + - [x] 1.1 Create OpenApiDocumentSorter.cs in Oproto.Lambda.OpenApi.Merge + - Implement `Sort(OpenApiDocument)` method as entry point + - Implement `SortPaths()` for alphabetical path ordering + - Implement `SortSchemas()` for alphabetical schema ordering + - Implement `SortSchemaProperties()` for alphabetical property ordering within schemas + - Implement `SortTags()` for alphabetical tag ordering + - Implement `SortSecuritySchemes()` for alphabetical security scheme ordering + - Implement `SortOperations()` for HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE) + - Implement `SortResponses()` for ascending status code order + - Implement `SortExamples()` for alphabetical example ordering + - Implement `SortTagGroups()` for alphabetical tag group and tag-within-group ordering + - Use `StringComparer.Ordinal` for consistent cross-platform ordering + - _Requirements: 1.1, 1.2, 2.1, 2.2, 3.1, 3.2, 4.1, 4.2, 5.1, 5.2, 5.3, 7.1, 7.2, 8.1, 8.2, 9.1, 9.2, 11.1, 11.2_ + + - [x] 1.2 Write property test for path ordering + - **Property 1: Path Ordering** + - **Validates: Requirements 1.1, 1.2** + + - [x] 1.3 Write property test for schema ordering + - **Property 2: Schema Ordering** + - **Validates: Requirements 2.1, 2.2** + + - [x] 1.4 Write property test for property ordering within schemas + - **Property 3: Property Ordering Within Schemas** + - **Validates: Requirements 3.1, 3.2** + + - [x] 1.5 Write property test for tag ordering + - **Property 4: Tag Ordering** + - **Validates: Requirements 4.1, 4.2** + + - [x] 1.6 Write property test for tag group ordering + - **Property 5: Tag Group Ordering** + - **Validates: Requirements 5.1, 5.2, 5.3** + + - [x] 1.7 Write property test for security scheme ordering + - **Property 6: Security Scheme Ordering** + - **Validates: Requirements 7.1, 7.2** + + - [x] 1.8 Write property test for operation ordering + - **Property 7: Operation Ordering** + - **Validates: Requirements 8.1, 8.2** + + - [x] 1.9 Write property test for response ordering + - **Property 8: Response Ordering** + - **Validates: Requirements 9.1, 9.2** + + - [x] 1.10 Write property test for example ordering + - **Property 9: Example Ordering** + - **Validates: Requirements 11.1, 11.2** + +- [x] 2. Integrate sorter into OpenApiMerger + - [x] 2.1 Modify OpenApiMerger.Merge() to call OpenApiDocumentSorter.Sort() + - Call Sort() on the merged document before returning + - Ensure sorting happens after all merge operations complete + - _Requirements: 1.2, 2.2, 3.2, 4.2, 5.2, 7.2, 8.2, 9.2, 11.2_ + + - [x] 2.2 Write property test for output idempotence + - **Property 10: Output Idempotence (Round-Trip)** + - **Validates: Requirements 1.3, 2.3, 3.3, 4.3** + +- [x] 3. Checkpoint - Ensure all merger tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Integrate sorter into Source Generator + - [x] 4.1 Add sorting logic to OpenApiSpecGenerator.MergeOpenApiDocs() + - Implement sorting methods directly in the source generator (cannot reference Merge project) + - Sort paths alphabetically + - Sort schemas alphabetically + - Sort properties within schemas alphabetically + - Sort tags alphabetically + - Sort security schemes alphabetically + - Sort operations by HTTP method order + - Sort responses by status code + - Sort examples alphabetically + - Sort tag groups and tags within groups alphabetically + - _Requirements: 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1_ + + - [x] 4.2 Write property test for generator deterministic output + - Test that generating the same source twice produces identical output + - **Validates: Requirements 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1** + +- [x] 5. Checkpoint - Ensure all generator tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Add skip-unchanged feature to merge tool + - [x] 6.1 Add --force flag to MergeCommand + - Add `-f, --force` option to command definition + - Pass force flag to write method + - _Requirements: 10.3_ + + - [x] 6.2 Implement skip-unchanged logic in WriteOpenApiDocumentAsync + - Read existing file content if file exists + - Compare with new content + - Skip write if content matches and force is false + - Log message when skipping + - Always write if file doesn't exist + - _Requirements: 10.1, 10.2, 10.4_ + + - [x] 6.3 Write unit tests for skip-unchanged feature + - Test file is skipped when content matches + - Test file is written when content differs + - Test file is written when --force is used + - Test file is written when it doesn't exist + - Test log message is output when skipping + - _Requirements: 10.1, 10.2, 10.3, 10.4_ + +- [x] 7. Add server order preservation test + - [x] 7.1 Write property test for server order preservation + - **Property 11: Server Order Preservation** + - **Validates: Requirements 6.1, 6.2** + +- [x] 8. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- All test tasks are required for comprehensive coverage +- The source generator cannot reference the Merge project, so sorting logic must be duplicated +- Use `StringComparer.Ordinal` for consistent cross-platform string ordering +- Property tests should use FsCheck with minimum 100 iterations +- Each property test should reference its design document property number diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ca741..40dbe67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Deterministic Output** + - OpenAPI output is now fully deterministic across multiple runs with identical input + - Paths sorted alphabetically by path string + - Schemas sorted alphabetically by schema name + - Properties within schemas sorted alphabetically by property name + - Tags sorted alphabetically by tag name + - Tag groups sorted alphabetically by group name, with tags within groups also sorted + - Security schemes sorted alphabetically by scheme name + - Operations within paths sorted by HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE) + - Responses sorted by status code in ascending order + - Examples sorted alphabetically by example name + - Server order preserved as declared in source code or configuration + +- **Skip Unchanged Output (Merge Tool)** + - Merge tool now skips writing output files when content matches existing file + - New `--force` (`-f`) flag to override skip behavior and always write output + - Verbose mode logs when files are skipped due to unchanged content + +### Changed + +- `OpenApiMerger.Merge()` now returns sorted documents for deterministic output +- Source generator now sorts all collections before serialization + ## [1.2.0] - 2025-12-22 ### Added diff --git a/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs b/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs new file mode 100644 index 0000000..e095e5b --- /dev/null +++ b/Oproto.Lambda.OpenApi.Merge.Tests/DeterministicOutputPropertyTests.cs @@ -0,0 +1,866 @@ +using FsCheck; +using FsCheck.Xunit; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; +using Oproto.Lambda.OpenApi.Merge; + +namespace Oproto.Lambda.OpenApi.Merge.Tests; + +/// +/// Property-based tests for OpenApiDocumentSorter deterministic output. +/// +public class DeterministicOutputPropertyTests +{ + /// + /// Generators for deterministic output test data. + /// + private static class SorterGenerators + { + public static Gen PathGen() + { + return from segmentCount in Gen.Choose(1, 3) + from segments in Gen.ListOf(segmentCount, PathSegmentGen()) + let path = "/" + string.Join("/", segments) + select path; + } + + public static Gen PathSegmentGen() + { + return Gen.OneOf( + Gen.Elements("users", "products", "orders", "items", "api", "v1", "v2", "admin", "auth", "config"), + Gen.Elements("{id}", "{userId}", "{productId}", "{orderId}") + ); + } + + public static Gen SchemaNameGen() + { + return Gen.Elements("User", "Product", "Order", "Item", "Response", "Request", "Data", "Result", + "Customer", "Invoice", "Payment", "Address", "Contact", "Category"); + } + + public static Gen PropertyNameGen() + { + return Gen.Elements("id", "name", "value", "count", "status", "email", "phone", "address", + "createdAt", "updatedAt", "description", "price", "quantity", "total"); + } + + public static Gen TagNameGen() + { + return Gen.Elements("Users", "Products", "Orders", "Items", "Admin", "Auth", "Config", + "Inventory", "Payments", "Reports", "Analytics", "Settings"); + } + + public static Gen SecuritySchemeNameGen() + { + return Gen.Elements("apiKey", "bearer", "oauth2", "basic", "openIdConnect", + "customAuth", "jwt", "session", "token"); + } + + public static Gen ExampleNameGen() + { + return Gen.Elements("success", "error", "notFound", "created", "updated", "deleted", + "validRequest", "invalidRequest", "emptyResponse", "fullResponse"); + } + + public static Gen TagGroupNameGen() + { + return Gen.Elements("User Management", "Product Catalog", "Order Processing", + "Administration", "Authentication", "Configuration", "Analytics"); + } + + public static Gen ServerUrlGen() + { + return Gen.Elements( + "https://api.example.com/v1", + "https://staging.example.com/v1", + "https://dev.example.com/v1", + "https://api.production.com", + "https://api.test.com", + "https://localhost:5000", + "https://api.internal.com/v2", + "https://gateway.example.com"); + } + + + public static Gen DocumentWithPathsGen() + { + return from pathCount in Gen.Choose(2, 5) + from paths in Gen.ListOf(pathCount, PathGen()) + let uniquePaths = paths.Distinct().ToList() + where uniquePaths.Count >= 2 + select CreateDocumentWithPaths(uniquePaths); + } + + public static Gen DocumentWithSchemasGen() + { + return from schemaCount in Gen.Choose(2, 5) + from schemaNames in Gen.ListOf(schemaCount, SchemaNameGen()) + let uniqueSchemas = schemaNames.Distinct().ToList() + where uniqueSchemas.Count >= 2 + select CreateDocumentWithSchemas(uniqueSchemas); + } + + public static Gen DocumentWithTagsGen() + { + return from tagCount in Gen.Choose(2, 5) + from tagNames in Gen.ListOf(tagCount, TagNameGen()) + let uniqueTags = tagNames.Distinct().ToList() + where uniqueTags.Count >= 2 + select CreateDocumentWithTags(uniqueTags); + } + + public static Gen DocumentWithSecuritySchemesGen() + { + return from schemeCount in Gen.Choose(2, 4) + from schemeNames in Gen.ListOf(schemeCount, SecuritySchemeNameGen()) + let uniqueSchemes = schemeNames.Distinct().ToList() + where uniqueSchemes.Count >= 2 + select CreateDocumentWithSecuritySchemes(uniqueSchemes); + } + + public static Gen DocumentWithTagGroupsGen() + { + return from groupCount in Gen.Choose(2, 4) + from groupNames in Gen.ListOf(groupCount, TagGroupNameGen()) + from tagCount in Gen.Choose(2, 4) + from tagNames in Gen.ListOf(tagCount, TagNameGen()) + let uniqueGroups = groupNames.Distinct().ToList() + let uniqueTags = tagNames.Distinct().ToList() + where uniqueGroups.Count >= 2 && uniqueTags.Count >= 2 + select CreateDocumentWithTagGroups(uniqueGroups, uniqueTags); + } + + public static Gen DocumentWithOperationsGen() + { + return from opCount in Gen.Choose(2, 5) + from opTypes in Gen.ListOf(opCount, Gen.Elements( + OperationType.Get, OperationType.Put, OperationType.Post, + OperationType.Delete, OperationType.Patch, OperationType.Options)) + let uniqueOps = opTypes.Distinct().ToList() + where uniqueOps.Count >= 2 + select CreateDocumentWithOperations(uniqueOps); + } + + public static Gen DocumentWithResponsesGen() + { + return from responseCount in Gen.Choose(2, 4) + from statusCodes in Gen.ListOf(responseCount, Gen.Elements("200", "201", "400", "404", "500", "default")) + let uniqueCodes = statusCodes.Distinct().ToList() + where uniqueCodes.Count >= 2 + select CreateDocumentWithResponses(uniqueCodes); + } + + public static Gen DocumentWithExamplesGen() + { + return from exampleCount in Gen.Choose(2, 4) + from exampleNames in Gen.ListOf(exampleCount, ExampleNameGen()) + let uniqueExamples = exampleNames.Distinct().ToList() + where uniqueExamples.Count >= 2 + select CreateDocumentWithExamples(uniqueExamples); + } + + public static Gen DocumentWithServersGen() + { + return from serverCount in Gen.Choose(2, 4) + from serverUrls in Gen.ListOf(serverCount, ServerUrlGen()) + let uniqueServers = serverUrls.Distinct().ToList() + where uniqueServers.Count >= 2 + select CreateDocumentWithServers(uniqueServers); + } + + public static Gen SchemaWithPropertiesGen() + { + return from propCount in Gen.Choose(2, 5) + from propNames in Gen.ListOf(propCount, PropertyNameGen()) + let uniqueProps = propNames.Distinct().ToList() + where uniqueProps.Count >= 2 + select CreateSchemaWithProperties(uniqueProps); + } + + /// + /// Generates a comprehensive OpenAPI document with multiple paths, schemas, tags, and operations + /// for testing round-trip idempotence. + /// + public static Gen ComprehensiveDocumentGen() + { + return from pathCount in Gen.Choose(2, 4) + from paths in Gen.ListOf(pathCount, PathGen()) + from schemaCount in Gen.Choose(2, 4) + from schemaNames in Gen.ListOf(schemaCount, SchemaNameGen()) + from tagCount in Gen.Choose(2, 4) + from tagNames in Gen.ListOf(tagCount, TagNameGen()) + from propCount in Gen.Choose(2, 4) + from propNames in Gen.ListOf(propCount, PropertyNameGen()) + let uniquePaths = paths.Distinct().ToList() + let uniqueSchemas = schemaNames.Distinct().ToList() + let uniqueTags = tagNames.Distinct().ToList() + let uniqueProps = propNames.Distinct().ToList() + where uniquePaths.Count >= 2 && uniqueSchemas.Count >= 2 && uniqueTags.Count >= 2 && uniqueProps.Count >= 2 + select CreateComprehensiveDocument(uniquePaths, uniqueSchemas, uniqueTags, uniqueProps); + } + + + private static OpenApiDocument CreateDocumentWithPaths(List paths) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths() + }; + + foreach (var path in paths) + { + doc.Paths[path] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + OperationId = "get" + path.Replace("/", "_").Replace("{", "").Replace("}", ""), + Summary = $"Get {path}" + } + } + }; + } + + return doc; + } + + private static OpenApiDocument CreateDocumentWithSchemas(List schemaNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Components = new OpenApiComponents + { + Schemas = new Dictionary() + } + }; + + foreach (var schemaName in schemaNames) + { + doc.Components.Schemas[schemaName] = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = "string" }, + ["name"] = new OpenApiSchema { Type = "string" } + } + }; + } + + return doc; + } + + private static OpenApiDocument CreateDocumentWithTags(List tagNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Tags = new List() + }; + + foreach (var tagName in tagNames) + { + doc.Tags.Add(new OpenApiTag { Name = tagName, Description = $"Description for {tagName}" }); + } + + return doc; + } + + private static OpenApiDocument CreateDocumentWithSecuritySchemes(List schemeNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary() + } + }; + + foreach (var schemeName in schemeNames) + { + doc.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + Name = schemeName, + In = ParameterLocation.Header, + Description = $"Security scheme {schemeName}" + }; + } + + return doc; + } + + + private static OpenApiDocument CreateDocumentWithTagGroups(List groupNames, List tagNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Extensions = new Dictionary() + }; + + var tagGroupsArray = new OpenApiArray(); + var tagIndex = 0; + foreach (var groupName in groupNames) + { + var tagsArray = new OpenApiArray(); + // Assign some tags to each group + var tagsForGroup = tagNames.Skip(tagIndex % tagNames.Count).Take(2).ToList(); + foreach (var tag in tagsForGroup) + { + tagsArray.Add(new OpenApiString(tag)); + } + tagIndex++; + + var groupObject = new OpenApiObject + { + ["name"] = new OpenApiString(groupName), + ["tags"] = tagsArray + }; + tagGroupsArray.Add(groupObject); + } + + doc.Extensions["x-tagGroups"] = tagGroupsArray; + return doc; + } + + private static OpenApiDocument CreateDocumentWithOperations(List operationTypes) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths() + }; + + var operations = new Dictionary(); + foreach (var opType in operationTypes) + { + operations[opType] = new OpenApiOperation + { + OperationId = opType.ToString().ToLower() + "Resource", + Summary = $"{opType} resource" + }; + } + + doc.Paths["/resource"] = new OpenApiPathItem { Operations = operations }; + return doc; + } + + private static OpenApiDocument CreateDocumentWithResponses(List statusCodes) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths() + }; + + var responses = new OpenApiResponses(); + foreach (var code in statusCodes) + { + responses[code] = new OpenApiResponse { Description = $"Response {code}" }; + } + + doc.Paths["/resource"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + OperationId = "getResource", + Responses = responses + } + } + }; + + return doc; + } + + private static OpenApiDocument CreateDocumentWithExamples(List exampleNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths() + }; + + var examples = new Dictionary(); + foreach (var name in exampleNames) + { + examples[name] = new OpenApiExample + { + Summary = $"Example {name}", + Value = new OpenApiString($"value for {name}") + }; + } + + doc.Paths["/resource"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + OperationId = "getResource", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "Success", + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Examples = examples + } + } + } + } + } + } + }; + + return doc; + } + + private static OpenApiDocument CreateDocumentWithServers(List serverUrls) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Servers = new List() + }; + + foreach (var url in serverUrls) + { + doc.Servers.Add(new OpenApiServer + { + Url = url, + Description = $"Server at {url}" + }); + } + + return doc; + } + + private static OpenApiSchema CreateSchemaWithProperties(List propertyNames) + { + var schema = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary() + }; + + foreach (var propName in propertyNames) + { + schema.Properties[propName] = new OpenApiSchema { Type = "string" }; + } + + return schema; + } + + private static OpenApiDocument CreateComprehensiveDocument( + List paths, + List schemaNames, + List tagNames, + List propertyNames) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Comprehensive Test API", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Tags = new List(), + Components = new OpenApiComponents + { + Schemas = new Dictionary() + } + }; + + // Add paths with multiple operations + foreach (var path in paths) + { + doc.Paths[path] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + OperationId = "get" + path.Replace("/", "_").Replace("{", "").Replace("}", ""), + Summary = $"Get {path}", + Tags = new List { new OpenApiTag { Name = tagNames.First() } }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" }, + ["404"] = new OpenApiResponse { Description = "Not found" } + } + }, + [OperationType.Post] = new OpenApiOperation + { + OperationId = "post" + path.Replace("/", "_").Replace("{", "").Replace("}", ""), + Summary = $"Create {path}", + Tags = new List { new OpenApiTag { Name = tagNames.Last() } }, + Responses = new OpenApiResponses + { + ["201"] = new OpenApiResponse { Description = "Created" }, + ["400"] = new OpenApiResponse { Description = "Bad request" } + } + } + } + }; + } + + // Add schemas with properties + foreach (var schemaName in schemaNames) + { + var schema = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary() + }; + + foreach (var propName in propertyNames) + { + schema.Properties[propName] = new OpenApiSchema { Type = "string" }; + } + + doc.Components.Schemas[schemaName] = schema; + } + + // Add tags + foreach (var tagName in tagNames) + { + doc.Tags.Add(new OpenApiTag { Name = tagName, Description = $"Description for {tagName}" }); + } + + return doc; + } + } + + + /// + /// Feature: deterministic-output, Property 1: Path Ordering + /// For any OpenAPI document with multiple paths, after sorting, the paths SHALL appear + /// in alphabetical order by path string using ordinal comparison. + /// **Validates: Requirements 1.1, 1.2** + /// + [Property(MaxTest = 100)] + public Property Paths_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithPathsGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var pathKeys = sortedDoc.Paths.Keys.ToList(); + + // Verify paths are in alphabetical order + var isSorted = pathKeys.SequenceEqual(pathKeys.OrderBy(p => p, StringComparer.Ordinal)); + + return isSorted.Label("Paths should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 2: Schema Ordering + /// For any OpenAPI document with multiple schemas, after sorting, the schemas SHALL appear + /// in alphabetical order by schema name using ordinal comparison. + /// **Validates: Requirements 2.1, 2.2** + /// + [Property(MaxTest = 100)] + public Property Schemas_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithSchemasGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var schemaKeys = sortedDoc.Components?.Schemas?.Keys.ToList() ?? new List(); + + // Verify schemas are in alphabetical order + var isSorted = schemaKeys.SequenceEqual(schemaKeys.OrderBy(s => s, StringComparer.Ordinal)); + + return isSorted.Label("Schemas should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 3: Property Ordering Within Schemas + /// For any OpenAPI schema with multiple properties, after sorting, the properties SHALL appear + /// in alphabetical order by property name using ordinal comparison. + /// **Validates: Requirements 3.1, 3.2** + /// + [Property(MaxTest = 100)] + public Property SchemaProperties_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.SchemaWithPropertiesGen().ToArbitrary(), + schema => + { + // Create a document with the schema to test through the public Sort method + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0.0" }, + Paths = new OpenApiPaths(), + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["TestSchema"] = schema + } + } + }; + + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var sortedSchema = sortedDoc.Components!.Schemas!["TestSchema"]; + var propKeys = sortedSchema.Properties?.Keys.ToList() ?? new List(); + + // Verify properties are in alphabetical order + var isSorted = propKeys.SequenceEqual(propKeys.OrderBy(p => p, StringComparer.Ordinal)); + + return isSorted.Label("Schema properties should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 4: Tag Ordering + /// For any OpenAPI document with multiple tags, after sorting, the tags SHALL appear + /// in alphabetical order by tag name using ordinal comparison. + /// **Validates: Requirements 4.1, 4.2** + /// + [Property(MaxTest = 100)] + public Property Tags_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithTagsGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var tagNames = sortedDoc.Tags?.Select(t => t.Name).ToList() ?? new List(); + + // Verify tags are in alphabetical order + var isSorted = tagNames.SequenceEqual(tagNames.OrderBy(t => t, StringComparer.Ordinal)); + + return isSorted.Label("Tags should be in alphabetical order"); + }); + } + + + /// + /// Feature: deterministic-output, Property 5: Tag Group Ordering + /// 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. + /// **Validates: Requirements 5.1, 5.2, 5.3** + /// + [Property(MaxTest = 100)] + public Property TagGroups_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithTagGroupsGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + + // Read tag groups from the sorted document + var tagGroups = OpenApiMerger.ReadTagGroupsExtension(sortedDoc); + var groupNames = tagGroups.Select(g => g.Name).ToList(); + + // Verify tag groups are in alphabetical order + var groupsSorted = groupNames.SequenceEqual(groupNames.OrderBy(g => g, StringComparer.Ordinal)); + + // Verify tags within each group are in alphabetical order + var tagsWithinGroupsSorted = tagGroups.All(g => + g.Tags.SequenceEqual(g.Tags.OrderBy(t => t, StringComparer.Ordinal))); + + return groupsSorted.Label("Tag groups should be in alphabetical order") + .And(tagsWithinGroupsSorted).Label("Tags within groups should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 6: Security Scheme Ordering + /// For any OpenAPI document with multiple security schemes, after sorting, the security schemes + /// SHALL appear in alphabetical order by scheme name using ordinal comparison. + /// **Validates: Requirements 7.1, 7.2** + /// + [Property(MaxTest = 100)] + public Property SecuritySchemes_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithSecuritySchemesGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var schemeKeys = sortedDoc.Components?.SecuritySchemes?.Keys.ToList() ?? new List(); + + // Verify security schemes are in alphabetical order + var isSorted = schemeKeys.SequenceEqual(schemeKeys.OrderBy(s => s, StringComparer.Ordinal)); + + return isSorted.Label("Security schemes should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 7: Operation Ordering + /// For any path with multiple operations, after sorting, the operations SHALL appear + /// in HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE. + /// **Validates: Requirements 8.1, 8.2** + /// + [Property(MaxTest = 100)] + public Property Operations_ShouldBeSorted_ByHttpMethodOrder() + { + return Prop.ForAll( + SorterGenerators.DocumentWithOperationsGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var operations = sortedDoc.Paths["/resource"].Operations.Keys.ToList(); + + // Expected order + var expectedOrder = new[] + { + OperationType.Get, OperationType.Put, OperationType.Post, + OperationType.Delete, OperationType.Options, OperationType.Head, + OperationType.Patch, OperationType.Trace + }; + + // Filter expected order to only include operations that exist + var expectedFiltered = expectedOrder.Where(op => operations.Contains(op)).ToList(); + + // Verify operations are in the expected order + var isSorted = operations.SequenceEqual(expectedFiltered); + + return isSorted.Label("Operations should be in HTTP method order (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE)"); + }); + } + + + /// + /// Feature: deterministic-output, Property 8: Response Ordering + /// 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). + /// **Validates: Requirements 9.1, 9.2** + /// + [Property(MaxTest = 100)] + public Property Responses_ShouldBeSorted_ByStatusCode() + { + return Prop.ForAll( + SorterGenerators.DocumentWithResponsesGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var responses = sortedDoc.Paths["/resource"].Operations[OperationType.Get].Responses.Keys.ToList(); + + // Verify responses are sorted by status code (numeric ascending, "default" last) + var expectedOrder = responses.OrderBy(key => + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase)) + return int.MaxValue; + if (int.TryParse(key, out var code)) + return code; + return int.MaxValue - 1; + }).ThenBy(key => key, StringComparer.Ordinal).ToList(); + + var isSorted = responses.SequenceEqual(expectedOrder); + + return isSorted.Label("Responses should be sorted by status code (ascending, 'default' last)"); + }); + } + + /// + /// Feature: deterministic-output, Property 9: Example Ordering + /// For any media type with multiple examples, after sorting, the examples SHALL appear + /// in alphabetical order by example name using ordinal comparison. + /// **Validates: Requirements 11.1, 11.2** + /// + [Property(MaxTest = 100)] + public Property Examples_ShouldBeSorted_Alphabetically() + { + return Prop.ForAll( + SorterGenerators.DocumentWithExamplesGen().ToArbitrary(), + doc => + { + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var examples = sortedDoc.Paths["/resource"] + .Operations[OperationType.Get] + .Responses["200"] + .Content["application/json"] + .Examples.Keys.ToList(); + + // Verify examples are in alphabetical order + var isSorted = examples.SequenceEqual(examples.OrderBy(e => e, StringComparer.Ordinal)); + + return isSorted.Label("Examples should be in alphabetical order"); + }); + } + + /// + /// Feature: deterministic-output, Property 10: Output Idempotence (Round-Trip) + /// For any valid OpenAPI document, serializing the document, then deserializing and + /// re-serializing SHALL produce identical JSON output. + /// **Validates: Requirements 1.3, 2.3, 3.3, 4.3** + /// + [Property(MaxTest = 100)] + public Property Output_ShouldBeIdempotent_AfterRoundTrip() + { + return Prop.ForAll( + SorterGenerators.ComprehensiveDocumentGen().ToArbitrary(), + doc => + { + // First sort and serialize + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + var firstJson = sortedDoc.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + + // Deserialize and re-serialize + var reader = new OpenApiStringReader(); + var parsedDoc = reader.Read(firstJson, out var diagnostic); + + // Sort again and serialize + var reSortedDoc = OpenApiDocumentSorter.Sort(parsedDoc); + var secondJson = reSortedDoc.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + + // The two JSON outputs should be identical + var isIdempotent = firstJson == secondJson; + + return isIdempotent.Label("Output should be identical after round-trip (serialize -> deserialize -> serialize)"); + }); + } + + /// + /// Feature: deterministic-output, Property 11: Server Order Preservation + /// For any OpenAPI document with servers, the servers SHALL appear in the same order + /// as they were declared in source code or configuration. + /// **Validates: Requirements 6.1, 6.2** + /// + [Property(MaxTest = 100)] + public Property Servers_ShouldPreserveDeclarationOrder() + { + return Prop.ForAll( + SorterGenerators.DocumentWithServersGen().ToArbitrary(), + doc => + { + // Capture the original server order before sorting + var originalServerUrls = doc.Servers?.Select(s => s.Url).ToList() ?? new List(); + + // Sort the document + var sortedDoc = OpenApiDocumentSorter.Sort(doc); + + // Get the server order after sorting + var sortedServerUrls = sortedDoc.Servers?.Select(s => s.Url).ToList() ?? new List(); + + // Verify servers maintain their original declaration order (not alphabetically sorted) + var orderPreserved = originalServerUrls.SequenceEqual(sortedServerUrls); + + return orderPreserved.Label("Servers should preserve their original declaration order after sorting"); + }); + } +} diff --git a/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs b/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs new file mode 100644 index 0000000..60bba51 --- /dev/null +++ b/Oproto.Lambda.OpenApi.Merge.Tests/SkipUnchangedTests.cs @@ -0,0 +1,264 @@ +namespace Oproto.Lambda.OpenApi.Merge.Tests; + +using Microsoft.OpenApi; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; +using Xunit; + +/// +/// Unit tests for the skip-unchanged feature in the merge tool. +/// Validates: Requirements 10.1, 10.2, 10.3, 10.4 +/// +public class SkipUnchangedTests : IDisposable +{ + private readonly string _testOutputDir; + private readonly List _createdFiles = new(); + + public SkipUnchangedTests() + { + _testOutputDir = Path.Combine(Path.GetTempPath(), $"skip-unchanged-tests-{Guid.NewGuid()}"); + Directory.CreateDirectory(_testOutputDir); + } + + public void Dispose() + { + // Cleanup test files + foreach (var file in _createdFiles) + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + + if (Directory.Exists(_testOutputDir)) + { + Directory.Delete(_testOutputDir, recursive: true); + } + } + + /// + /// Tests that file is skipped when content matches and force is false. + /// Validates: Requirements 10.1 + /// + [Fact] + public async Task WriteOpenApiDocument_ContentMatches_SkipsWrite() + { + // Arrange + var document = CreateSimpleOpenApiDocument("Test API", "1.0.0"); + var outputPath = Path.Combine(_testOutputDir, "skip-test.json"); + _createdFiles.Add(outputPath); + + // Write initial file + var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + await File.WriteAllTextAsync(outputPath, json); + var originalWriteTime = File.GetLastWriteTimeUtc(outputPath); + + // Wait a bit to ensure timestamp would change if file is rewritten + await Task.Delay(50); + + // Act - Try to write the same content without force + var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: false); + + // Assert + Assert.False(wasWritten, "File should not have been written when content matches"); + var newWriteTime = File.GetLastWriteTimeUtc(outputPath); + Assert.Equal(originalWriteTime, newWriteTime); + } + + /// + /// Tests that file is written when content differs. + /// Validates: Requirements 10.1 + /// + [Fact] + public async Task WriteOpenApiDocument_ContentDiffers_WritesFile() + { + // Arrange + var originalDocument = CreateSimpleOpenApiDocument("Original API", "1.0.0"); + var updatedDocument = CreateSimpleOpenApiDocument("Updated API", "2.0.0"); + var outputPath = Path.Combine(_testOutputDir, "diff-test.json"); + _createdFiles.Add(outputPath); + + // Write initial file + var originalJson = originalDocument.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + await File.WriteAllTextAsync(outputPath, originalJson); + + // Act - Write different content without force + var wasWritten = await WriteOpenApiDocumentAsync(updatedDocument, outputPath, verbose: false, force: false); + + // Assert + Assert.True(wasWritten, "File should have been written when content differs"); + var newContent = await File.ReadAllTextAsync(outputPath); + Assert.Contains("Updated API", newContent); + } + + /// + /// Tests that file is written when --force is used even if content matches. + /// Validates: Requirements 10.3 + /// + [Fact] + public async Task WriteOpenApiDocument_ForceFlag_WritesEvenWhenContentMatches() + { + // Arrange + var document = CreateSimpleOpenApiDocument("Force Test API", "1.0.0"); + var outputPath = Path.Combine(_testOutputDir, "force-test.json"); + _createdFiles.Add(outputPath); + + // Write initial file + var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + await File.WriteAllTextAsync(outputPath, json); + var originalWriteTime = File.GetLastWriteTimeUtc(outputPath); + + // Wait a bit to ensure timestamp would change if file is rewritten + await Task.Delay(50); + + // Act - Write with force flag + var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: true); + + // Assert + Assert.True(wasWritten, "File should have been written when force is true"); + var newWriteTime = File.GetLastWriteTimeUtc(outputPath); + Assert.True(newWriteTime > originalWriteTime, "File timestamp should have changed"); + } + + /// + /// Tests that file is written when it doesn't exist. + /// Validates: Requirements 10.4 + /// + [Fact] + public async Task WriteOpenApiDocument_FileDoesNotExist_WritesFile() + { + // Arrange + var document = CreateSimpleOpenApiDocument("New File API", "1.0.0"); + var outputPath = Path.Combine(_testOutputDir, "new-file-test.json"); + _createdFiles.Add(outputPath); + + // Ensure file doesn't exist + Assert.False(File.Exists(outputPath)); + + // Act + var wasWritten = await WriteOpenApiDocumentAsync(document, outputPath, verbose: false, force: false); + + // Assert + Assert.True(wasWritten, "File should have been written when it doesn't exist"); + Assert.True(File.Exists(outputPath), "File should exist after write"); + } + + /// + /// Tests that log message is output when skipping in verbose mode. + /// Validates: Requirements 10.2 + /// + [Fact] + public async Task WriteOpenApiDocument_SkipWithVerbose_LogsMessage() + { + // Arrange + var document = CreateSimpleOpenApiDocument("Verbose Test API", "1.0.0"); + var outputPath = Path.Combine(_testOutputDir, "verbose-test.json"); + _createdFiles.Add(outputPath); + + // Write initial file + var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + await File.WriteAllTextAsync(outputPath, json); + + // Capture console output + var originalOut = Console.Out; + using var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + + try + { + // Act + await WriteOpenApiDocumentAsync(document, outputPath, verbose: true, force: false); + + // Assert + var output = stringWriter.ToString(); + Assert.Contains("unchanged", output.ToLowerInvariant()); + Assert.Contains("skipping", output.ToLowerInvariant()); + } + finally + { + Console.SetOut(originalOut); + } + } + + #region Helper Methods + + private static OpenApiDocument CreateSimpleOpenApiDocument(string title, string version) + { + return new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = title, + Version = version + }, + Paths = new OpenApiPaths + { + ["/test"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + Summary = "Test operation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + } + } + } + } + }; + } + + /// + /// Simulates the WriteOpenApiDocumentAsync logic from MergeCommand for testing. + /// This mirrors the implementation in MergeCommand.cs. + /// + private static async Task WriteOpenApiDocumentAsync( + OpenApiDocument document, + string outputPath, + bool verbose, + bool force) + { + // Ensure output directory exists + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + + // Check if file exists and content matches (skip-unchanged feature) + if (!force && File.Exists(outputPath)) + { + try + { + var existingContent = await File.ReadAllTextAsync(outputPath); + if (existingContent == json) + { + if (verbose) + { + Console.WriteLine($"Output unchanged, skipping write: {outputPath}"); + } + return false; // Indicates file was not written + } + } + catch (IOException ex) + { + // Log warning and proceed with write if we can't read existing file + if (verbose) + { + Console.Error.WriteLine($"Warning: Could not read existing file for comparison: {ex.Message}"); + } + } + } + + await File.WriteAllTextAsync(outputPath, json); + return true; // Indicates file was written + } + + #endregion +} diff --git a/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs b/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs index 41d9b2f..0624a44 100644 --- a/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs +++ b/Oproto.Lambda.OpenApi.Merge.Tool/Commands/MergeCommand.cs @@ -43,6 +43,10 @@ public MergeCommand() : base("merge", "Merge multiple OpenAPI specifications") new[] { "-v", "--verbose" }, "Show detailed progress and warnings"); + var forceOption = new Option( + new[] { "-f", "--force" }, + "Force write output even if unchanged"); + // Positional argument for direct file list var filesArgument = new Argument( "files", @@ -57,10 +61,11 @@ public MergeCommand() : base("merge", "Merge multiple OpenAPI specifications") AddOption(versionOption); AddOption(schemaConflictOption); AddOption(verboseOption); + AddOption(forceOption); AddArgument(filesArgument); this.SetHandler(ExecuteAsync, configOption, outputOption, titleOption, - versionOption, schemaConflictOption, verboseOption, filesArgument); + versionOption, schemaConflictOption, verboseOption, forceOption, filesArgument); } private async Task ExecuteAsync( @@ -70,6 +75,7 @@ private async Task ExecuteAsync( string? version, SchemaConflictStrategy schemaConflict, bool verbose, + bool force, FileInfo[] files) { try @@ -147,7 +153,7 @@ private async Task ExecuteAsync( Console.WriteLine($"Writing merged specification to: {outputPath}"); } - await WriteOpenApiDocumentAsync(result.Document, outputPath, verbose); + var wasWritten = await WriteOpenApiDocumentAsync(result.Document, outputPath, verbose, force); if (verbose) { @@ -155,7 +161,14 @@ private async Task ExecuteAsync( } else { - Console.WriteLine($"Merged {documents.Count} specifications into {outputPath}"); + if (wasWritten) + { + Console.WriteLine($"Merged {documents.Count} specifications into {outputPath}"); + } + else + { + Console.WriteLine($"Output unchanged, skipped writing: {outputPath}"); + } } return 0; @@ -334,7 +347,7 @@ private static async Task LoadOpenApiDocumentAsync(string path, return result.OpenApiDocument; } - private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, string outputPath, bool verbose) + private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, string outputPath, bool verbose, bool force) { // Validate the merged document before writing var errors = document.Validate(Microsoft.OpenApi.Validations.ValidationRuleSet.GetDefaultRuleSet()); @@ -361,6 +374,33 @@ private static async Task WriteOpenApiDocumentAsync(OpenApiDocument document, st } var json = document.SerializeAsJson(OpenApiSpecVersion.OpenApi3_0); + + // Check if file exists and content matches (skip-unchanged feature) + if (!force && File.Exists(outputPath)) + { + try + { + var existingContent = await File.ReadAllTextAsync(outputPath); + if (existingContent == json) + { + if (verbose) + { + Console.WriteLine($"Output unchanged, skipping write: {outputPath}"); + } + return false; // Indicates file was not written + } + } + catch (IOException ex) + { + // Log warning and proceed with write if we can't read existing file + if (verbose) + { + Console.Error.WriteLine($"Warning: Could not read existing file for comparison: {ex.Message}"); + } + } + } + await File.WriteAllTextAsync(outputPath, json); + return true; // Indicates file was written } } diff --git a/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs b/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs new file mode 100644 index 0000000..2e0fec3 --- /dev/null +++ b/Oproto.Lambda.OpenApi.Merge/OpenApiDocumentSorter.cs @@ -0,0 +1,389 @@ +namespace Oproto.Lambda.OpenApi.Merge; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +/// +/// Sorts OpenAPI document collections for deterministic output. +/// +public static class OpenApiDocumentSorter +{ + /// + /// HTTP method ordering for operations within a path. + /// + private static readonly OperationType[] OperationOrder = + { + OperationType.Get, + OperationType.Put, + OperationType.Post, + OperationType.Delete, + OperationType.Options, + OperationType.Head, + OperationType.Patch, + OperationType.Trace + }; + + /// + /// Sorts all collections in the document for deterministic output. + /// Modifies the document in place and returns it. + /// + /// The OpenAPI document to sort. + /// The sorted document (same instance, modified in place). + public static OpenApiDocument Sort(OpenApiDocument document) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + // Sort paths + if (document.Paths != null && document.Paths.Count > 0) + { + document.Paths = SortPaths(document.Paths); + } + + // Sort schemas + if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0) + { + document.Components.Schemas = SortSchemas(document.Components.Schemas); + } + + // Sort tags + if (document.Tags != null && document.Tags.Count > 0) + { + document.Tags = SortTags(document.Tags); + } + + // Sort security schemes + if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0) + { + document.Components.SecuritySchemes = SortSecuritySchemes(document.Components.SecuritySchemes); + } + + // Sort tag groups + SortTagGroups(document); + + return document; + } + + + /// + /// Sorts paths alphabetically by path string. + /// + /// The paths to sort. + /// A new OpenApiPaths with sorted entries. + internal static OpenApiPaths SortPaths(OpenApiPaths paths) + { + if (paths == null || paths.Count == 0) + return paths ?? new OpenApiPaths(); + + var sortedPaths = new OpenApiPaths(); + foreach (var path in paths.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + sortedPaths[path.Key] = SortPathItem(path.Value); + } + return sortedPaths; + } + + /// + /// Sorts operations within a path item and their responses/examples. + /// + private static OpenApiPathItem SortPathItem(OpenApiPathItem pathItem) + { + if (pathItem?.Operations == null || pathItem.Operations.Count == 0) + return pathItem ?? new OpenApiPathItem(); + + pathItem.Operations = SortOperations(pathItem.Operations); + return pathItem; + } + + /// + /// Sorts schemas alphabetically by name and sorts properties within each schema. + /// + /// The schemas to sort. + /// A new dictionary with sorted entries. + internal static IDictionary SortSchemas(IDictionary schemas) + { + if (schemas == null || schemas.Count == 0) + return schemas ?? new Dictionary(); + + var sortedSchemas = new Dictionary(); + foreach (var schema in schemas.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value); + } + return sortedSchemas; + } + + /// + /// Sorts properties within a schema alphabetically. + /// + /// The schema to sort. + /// The schema with sorted properties. + internal static OpenApiSchema SortSchemaProperties(OpenApiSchema schema) + { + if (schema == null) + return new OpenApiSchema(); + + if (schema.Properties != null && schema.Properties.Count > 0) + { + var sortedProperties = new Dictionary(); + foreach (var prop in schema.Properties.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + // Recursively sort nested schema properties + sortedProperties[prop.Key] = SortSchemaProperties(prop.Value); + } + schema.Properties = sortedProperties; + } + + // Sort items schema if present (for arrays) + if (schema.Items != null) + { + schema.Items = SortSchemaProperties(schema.Items); + } + + // Sort additionalProperties schema if present + if (schema.AdditionalProperties != null) + { + schema.AdditionalProperties = SortSchemaProperties(schema.AdditionalProperties); + } + + // Sort allOf, oneOf, anyOf schemas + if (schema.AllOf != null && schema.AllOf.Count > 0) + { + schema.AllOf = schema.AllOf.Select(SortSchemaProperties).ToList(); + } + if (schema.OneOf != null && schema.OneOf.Count > 0) + { + schema.OneOf = schema.OneOf.Select(SortSchemaProperties).ToList(); + } + if (schema.AnyOf != null && schema.AnyOf.Count > 0) + { + schema.AnyOf = schema.AnyOf.Select(SortSchemaProperties).ToList(); + } + + return schema; + } + + + /// + /// Sorts tags alphabetically by name. + /// + /// The tags to sort. + /// A new list with sorted tags. + internal static IList SortTags(IList tags) + { + if (tags == null || tags.Count == 0) + return tags ?? new List(); + + return tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); + } + + /// + /// Sorts security schemes alphabetically by name. + /// + /// The security schemes to sort. + /// A new dictionary with sorted entries. + internal static IDictionary SortSecuritySchemes( + IDictionary schemes) + { + if (schemes == null || schemes.Count == 0) + return schemes ?? new Dictionary(); + + var sortedSchemes = new Dictionary(); + foreach (var scheme in schemes.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemes[scheme.Key] = scheme.Value; + } + return sortedSchemes; + } + + /// + /// Sorts operations within a path by HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE. + /// + /// The operations to sort. + /// A new dictionary with sorted entries. + internal static IDictionary SortOperations( + IDictionary operations) + { + if (operations == null || operations.Count == 0) + return operations ?? new Dictionary(); + + var sortedOperations = new Dictionary(); + foreach (var opType in OperationOrder) + { + if (operations.TryGetValue(opType, out var operation)) + { + // Sort responses and examples within the operation + sortedOperations[opType] = SortOperation(operation); + } + } + return sortedOperations; + } + + /// + /// Sorts responses and examples within an operation. + /// + private static OpenApiOperation SortOperation(OpenApiOperation operation) + { + if (operation == null) + return new OpenApiOperation(); + + // Sort responses + if (operation.Responses != null && operation.Responses.Count > 0) + { + operation.Responses = SortResponses(operation.Responses); + } + + // Sort request body examples if present + if (operation.RequestBody?.Content != null) + { + foreach (var content in operation.RequestBody.Content.Values) + { + if (content.Examples != null && content.Examples.Count > 0) + { + content.Examples = SortExamples(content.Examples); + } + } + } + + return operation; + } + + + /// + /// Sorts responses by status code (ascending), with "default" sorted last. + /// + /// The responses to sort. + /// A new OpenApiResponses with sorted entries. + internal static OpenApiResponses SortResponses(OpenApiResponses responses) + { + if (responses == null || responses.Count == 0) + return responses ?? new OpenApiResponses(); + + var sortedResponses = new OpenApiResponses(); + + // Sort by status code: numeric codes first (ascending), then "default" last + var sortedKeys = responses.Keys.OrderBy(key => + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase)) + return int.MaxValue; + if (int.TryParse(key, out var code)) + return code; + return int.MaxValue - 1; // Unknown non-numeric codes before "default" + }).ThenBy(key => key, StringComparer.Ordinal); + + foreach (var key in sortedKeys) + { + var response = responses[key]; + // Sort examples within response content + if (response.Content != null) + { + foreach (var content in response.Content.Values) + { + if (content.Examples != null && content.Examples.Count > 0) + { + content.Examples = SortExamples(content.Examples); + } + } + } + sortedResponses[key] = response; + } + return sortedResponses; + } + + /// + /// Sorts examples alphabetically by name. + /// + /// The examples to sort. + /// A new dictionary with sorted entries. + internal static IDictionary SortExamples(IDictionary examples) + { + if (examples == null || examples.Count == 0) + return examples ?? new Dictionary(); + + var sortedExamples = new Dictionary(); + foreach (var example in examples.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + sortedExamples[example.Key] = example.Value; + } + return sortedExamples; + } + + /// + /// Sorts tag groups alphabetically by name, and tags within groups alphabetically. + /// + /// The document containing tag groups to sort. + internal static void SortTagGroups(OpenApiDocument document) + { + if (document?.Extensions == null) + return; + + if (!document.Extensions.TryGetValue("x-tagGroups", out var extension)) + return; + + if (extension is not OpenApiArray groupsArray || groupsArray.Count == 0) + return; + + // Parse tag groups + var tagGroups = new List<(string Name, List Tags)>(); + foreach (var item in groupsArray) + { + if (item is not OpenApiObject groupObj) + continue; + + string? name = null; + if (groupObj.TryGetValue("name", out var nameValue) && nameValue is OpenApiString nameString) + { + name = nameString.Value; + } + + if (string.IsNullOrEmpty(name)) + continue; + + var tags = new List(); + if (groupObj.TryGetValue("tags", out var tagsValue) && tagsValue is OpenApiArray tagsArray) + { + foreach (var tagItem in tagsArray.OfType()) + { + if (!string.IsNullOrEmpty(tagItem.Value)) + { + tags.Add(tagItem.Value); + } + } + } + + tagGroups.Add((name!, tags)); + } + + if (tagGroups.Count == 0) + return; + + // Sort tag groups by name, and tags within each group + var sortedGroups = tagGroups + .OrderBy(g => g.Name, StringComparer.Ordinal) + .Select(g => (g.Name, Tags: g.Tags.OrderBy(t => t, StringComparer.Ordinal).ToList())) + .ToList(); + + // Rebuild the extension + var sortedArray = new OpenApiArray(); + foreach (var group in sortedGroups) + { + var tagsArray = new OpenApiArray(); + foreach (var tag in group.Tags) + { + tagsArray.Add(new OpenApiString(tag)); + } + + var groupObject = new OpenApiObject + { + ["name"] = new OpenApiString(group.Name), + ["tags"] = tagsArray + }; + sortedArray.Add(groupObject); + } + + document.Extensions["x-tagGroups"] = sortedArray; + } +} diff --git a/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs b/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs index e3dd760..8e9815e 100644 --- a/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs +++ b/Oproto.Lambda.OpenApi.Merge/OpenApiMerger.cs @@ -112,6 +112,9 @@ public MergeResult Merge( // Phase 5: Merge tag groups from all sources MergeTagGroups(mergedDocument, documentList); + // Phase 6: Sort for deterministic output + OpenApiDocumentSorter.Sort(mergedDocument); + return new MergeResult(mergedDocument, warnings, success: true); } diff --git a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs index 2f1517b..5172a10 100644 --- a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs +++ b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs @@ -181,7 +181,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs, // Apply tag groups extension ApplyTagGroupsExtension(emptyDoc, tagGroups); - return emptyDoc; + // Sort for deterministic output + return SortDocument(emptyDoc); } if (validDocs.Count == 1) @@ -204,7 +205,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs, // Apply tag groups extension ApplyTagGroupsExtension(doc, tagGroups); - return doc; + // Sort for deterministic output + return SortDocument(doc); } var mergedDoc = new OpenApiDocument @@ -245,7 +247,8 @@ private OpenApiDocument MergeOpenApiDocs(ImmutableArray docs, // Apply tag groups extension ApplyTagGroupsExtension(mergedDoc, tagGroups); - return mergedDoc; + // Sort for deterministic output + return SortDocument(mergedDoc); } /// diff --git a/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs new file mode 100644 index 0000000..35ecd94 --- /dev/null +++ b/Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator_Sorting.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace Oproto.Lambda.OpenApi.SourceGenerator; + +/// +/// Partial class containing sorting methods for deterministic OpenAPI output. +/// +public partial class OpenApiSpecGenerator +{ + /// + /// HTTP method ordering for operations within a path. + /// + private static readonly OperationType[] OperationOrder = + { + OperationType.Get, + OperationType.Put, + OperationType.Post, + OperationType.Delete, + OperationType.Options, + OperationType.Head, + OperationType.Patch, + OperationType.Trace + }; + + /// + /// Sorts all collections in the document for deterministic output. + /// Modifies the document in place and returns it. + /// + /// The OpenAPI document to sort. + /// The sorted document (same instance, modified in place). + private static OpenApiDocument SortDocument(OpenApiDocument document) + { + if (document == null) + return new OpenApiDocument(); + + // Sort paths + if (document.Paths != null && document.Paths.Count > 0) + { + document.Paths = SortPaths(document.Paths); + } + + // Sort schemas + if (document.Components?.Schemas != null && document.Components.Schemas.Count > 0) + { + document.Components.Schemas = SortSchemas(document.Components.Schemas); + } + + // Sort tags + if (document.Tags != null && document.Tags.Count > 0) + { + document.Tags = SortTags(document.Tags); + } + + // Sort security schemes + if (document.Components?.SecuritySchemes != null && document.Components.SecuritySchemes.Count > 0) + { + document.Components.SecuritySchemes = SortSecuritySchemes(document.Components.SecuritySchemes); + } + + // Sort tag groups + SortTagGroupsExtension(document); + + return document; + } + + /// + /// Sorts paths alphabetically by path string. + /// + private static OpenApiPaths SortPaths(OpenApiPaths paths) + { + if (paths == null || paths.Count == 0) + return paths ?? new OpenApiPaths(); + + var sortedPaths = new OpenApiPaths(); + foreach (var path in paths.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + sortedPaths[path.Key] = SortPathItem(path.Value); + } + return sortedPaths; + } + + /// + /// Sorts operations within a path item and their responses/examples. + /// + private static OpenApiPathItem SortPathItem(OpenApiPathItem pathItem) + { + if (pathItem?.Operations == null || pathItem.Operations.Count == 0) + return pathItem ?? new OpenApiPathItem(); + + pathItem.Operations = SortOperations(pathItem.Operations); + return pathItem; + } + + /// + /// Sorts schemas alphabetically by name and sorts properties within each schema. + /// + private static IDictionary SortSchemas(IDictionary schemas) + { + if (schemas == null || schemas.Count == 0) + return schemas ?? new Dictionary(); + + var sortedSchemas = new Dictionary(); + foreach (var schema in schemas.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemas[schema.Key] = SortSchemaProperties(schema.Value); + } + return sortedSchemas; + } + + /// + /// Sorts properties within a schema alphabetically. + /// + private static OpenApiSchema SortSchemaProperties(OpenApiSchema schema) + { + if (schema == null) + return new OpenApiSchema(); + + if (schema.Properties != null && schema.Properties.Count > 0) + { + var sortedProperties = new Dictionary(); + foreach (var prop in schema.Properties.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + // Recursively sort nested schema properties + sortedProperties[prop.Key] = SortSchemaProperties(prop.Value); + } + schema.Properties = sortedProperties; + } + + // Sort items schema if present (for arrays) + if (schema.Items != null) + { + schema.Items = SortSchemaProperties(schema.Items); + } + + // Sort additionalProperties schema if present + if (schema.AdditionalProperties != null) + { + schema.AdditionalProperties = SortSchemaProperties(schema.AdditionalProperties); + } + + // Sort allOf, oneOf, anyOf schemas + if (schema.AllOf != null && schema.AllOf.Count > 0) + { + schema.AllOf = schema.AllOf.Select(SortSchemaProperties).ToList(); + } + if (schema.OneOf != null && schema.OneOf.Count > 0) + { + schema.OneOf = schema.OneOf.Select(SortSchemaProperties).ToList(); + } + if (schema.AnyOf != null && schema.AnyOf.Count > 0) + { + schema.AnyOf = schema.AnyOf.Select(SortSchemaProperties).ToList(); + } + + return schema; + } + + /// + /// Sorts tags alphabetically by name. + /// + private static IList SortTags(IList tags) + { + if (tags == null || tags.Count == 0) + return tags ?? new List(); + + return tags.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); + } + + /// + /// Sorts security schemes alphabetically by name. + /// + private static IDictionary SortSecuritySchemes( + IDictionary schemes) + { + if (schemes == null || schemes.Count == 0) + return schemes ?? new Dictionary(); + + var sortedSchemes = new Dictionary(); + foreach (var scheme in schemes.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + sortedSchemes[scheme.Key] = scheme.Value; + } + return sortedSchemes; + } + + /// + /// Sorts operations within a path by HTTP method order: GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE. + /// + private static IDictionary SortOperations( + IDictionary operations) + { + if (operations == null || operations.Count == 0) + return operations ?? new Dictionary(); + + var sortedOperations = new Dictionary(); + foreach (var opType in OperationOrder) + { + if (operations.TryGetValue(opType, out var operation)) + { + // Sort responses and examples within the operation + sortedOperations[opType] = SortOperation(operation); + } + } + return sortedOperations; + } + + /// + /// Sorts responses and examples within an operation. + /// + private static OpenApiOperation SortOperation(OpenApiOperation operation) + { + if (operation == null) + return new OpenApiOperation(); + + // Sort responses + if (operation.Responses != null && operation.Responses.Count > 0) + { + operation.Responses = SortResponses(operation.Responses); + } + + // Sort request body examples if present + if (operation.RequestBody?.Content != null) + { + foreach (var content in operation.RequestBody.Content.Values) + { + if (content.Examples != null && content.Examples.Count > 0) + { + content.Examples = SortExamples(content.Examples); + } + } + } + + return operation; + } + + /// + /// Sorts responses by status code (ascending), with "default" sorted last. + /// + private static OpenApiResponses SortResponses(OpenApiResponses responses) + { + if (responses == null || responses.Count == 0) + return responses ?? new OpenApiResponses(); + + var sortedResponses = new OpenApiResponses(); + + // Sort by status code: numeric codes first (ascending), then "default" last + var sortedKeys = responses.Keys.OrderBy(key => + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase)) + return int.MaxValue; + if (int.TryParse(key, out var code)) + return code; + return int.MaxValue - 1; // Unknown non-numeric codes before "default" + }).ThenBy(key => key, StringComparer.Ordinal); + + foreach (var key in sortedKeys) + { + var response = responses[key]; + // Sort examples within response content + if (response.Content != null) + { + foreach (var content in response.Content.Values) + { + if (content.Examples != null && content.Examples.Count > 0) + { + content.Examples = SortExamples(content.Examples); + } + } + } + sortedResponses[key] = response; + } + return sortedResponses; + } + + /// + /// Sorts examples alphabetically by name. + /// + private static IDictionary SortExamples(IDictionary examples) + { + if (examples == null || examples.Count == 0) + return examples ?? new Dictionary(); + + var sortedExamples = new Dictionary(); + foreach (var example in examples.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + sortedExamples[example.Key] = example.Value; + } + return sortedExamples; + } + + /// + /// Sorts tag groups alphabetically by name, and tags within groups alphabetically. + /// + private static void SortTagGroupsExtension(OpenApiDocument document) + { + if (document?.Extensions == null) + return; + + if (!document.Extensions.TryGetValue("x-tagGroups", out var extension)) + return; + + if (extension is not OpenApiArray groupsArray || groupsArray.Count == 0) + return; + + // Parse tag groups + var tagGroups = new List<(string Name, List Tags)>(); + foreach (var item in groupsArray) + { + if (item is not OpenApiObject groupObj) + continue; + + string name = null; + if (groupObj.TryGetValue("name", out var nameValue) && nameValue is OpenApiString nameString) + { + name = nameString.Value; + } + + if (string.IsNullOrEmpty(name)) + continue; + + var tags = new List(); + if (groupObj.TryGetValue("tags", out var tagsValue) && tagsValue is OpenApiArray tagsArray) + { + foreach (var tagItem in tagsArray.OfType()) + { + if (!string.IsNullOrEmpty(tagItem.Value)) + { + tags.Add(tagItem.Value); + } + } + } + + tagGroups.Add((name, tags)); + } + + if (tagGroups.Count == 0) + return; + + // Sort tag groups by name, and tags within each group + var sortedGroups = tagGroups + .OrderBy(g => g.Name, StringComparer.Ordinal) + .Select(g => (g.Name, Tags: g.Tags.OrderBy(t => t, StringComparer.Ordinal).ToList())) + .ToList(); + + // Rebuild the extension + var sortedArray = new OpenApiArray(); + foreach (var group in sortedGroups) + { + var tagsArray = new OpenApiArray(); + foreach (var tag in group.Tags) + { + tagsArray.Add(new OpenApiString(tag)); + } + + var groupObject = new OpenApiObject + { + ["name"] = new OpenApiString(group.Name), + ["tags"] = tagsArray + }; + sortedArray.Add(groupObject); + } + + document.Extensions["x-tagGroups"] = sortedArray; + } +} diff --git a/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs b/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs new file mode 100644 index 0000000..b81500a --- /dev/null +++ b/Oproto.Lambda.OpenApi.Tests/DeterministicGeneratorPropertyTests.cs @@ -0,0 +1,454 @@ +using System.Text.Json; +using FsCheck; +using FsCheck.Xunit; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Oproto.Lambda.OpenApi.SourceGenerator; + +namespace Oproto.Lambda.OpenApi.Tests; + +/// +/// Property-based tests for deterministic output from the OpenAPI source generator. +/// +public class DeterministicGeneratorPropertyTests +{ + /// + /// Generators for deterministic output test data. + /// + private static class GeneratorTestData + { + public static Gen PathSegmentGen() + { + return Gen.Elements("users", "products", "orders", "items", "api", "v1", "admin", "auth"); + } + + public static Gen TagNameGen() + { + return Gen.Elements("Users", "Products", "Orders", "Items", "Admin", "Auth"); + } + + public static Gen PropertyNameGen() + { + return Gen.Elements("id", "name", "value", "count", "status", "email", "phone", "address"); + } + + public static Gen SchemaNameGen() + { + return Gen.Elements("User", "Product", "Order", "Item", "Response", "Request"); + } + + public static Gen TagGroupNameGen() + { + return Gen.Elements("User Management", "Product Catalog", "Order Processing", "Administration"); + } + + /// + /// Generates source code with multiple paths in random order. + /// + public static Gen SourceWithMultiplePathsGen() + { + return from pathCount in Gen.Choose(2, 4) + from segments in Gen.ListOf(pathCount, PathSegmentGen()) + let uniqueSegments = segments.Distinct().ToList() + where uniqueSegments.Count >= 2 + select GenerateSourceWithPaths(uniqueSegments); + } + + /// + /// Generates source code with multiple tags in random order. + /// + public static Gen SourceWithMultipleTagsGen() + { + return from tagCount in Gen.Choose(2, 4) + from tags in Gen.ListOf(tagCount, TagNameGen()) + let uniqueTags = tags.Distinct().ToList() + where uniqueTags.Count >= 2 + select GenerateSourceWithTags(uniqueTags); + } + + /// + /// Generates source code with a schema containing multiple properties. + /// + public static Gen SourceWithSchemaPropertiesGen() + { + return from propCount in Gen.Choose(2, 5) + from props in Gen.ListOf(propCount, PropertyNameGen()) + let uniqueProps = props.Distinct().ToList() + where uniqueProps.Count >= 2 + select GenerateSourceWithSchemaProperties(uniqueProps); + } + + /// + /// Generates source code with multiple tag groups. + /// + public static Gen SourceWithTagGroupsGen() + { + return from groupCount in Gen.Choose(2, 3) + from groups in Gen.ListOf(groupCount, TagGroupNameGen()) + from tagCount in Gen.Choose(2, 3) + from tags in Gen.ListOf(tagCount, TagNameGen()) + let uniqueGroups = groups.Distinct().ToList() + let uniqueTags = tags.Distinct().ToList() + where uniqueGroups.Count >= 2 && uniqueTags.Count >= 2 + select GenerateSourceWithTagGroups(uniqueGroups, uniqueTags); + } + + private static string GenerateSourceWithPaths(List pathSegments) + { + var methods = string.Join("\n", pathSegments.Select((segment, index) => $@" + [LambdaFunction] + [HttpApi(LambdaHttpMethod.Get, ""/{segment}"")] + [OpenApiTag(""Default"")] + public string Get{char.ToUpper(segment[0])}{segment.Substring(1)}() => ""test"";")); + + return $@" +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Oproto.Lambda.OpenApi.Attributes; + +[assembly: OpenApiInfo(""Test API"", ""1.0.0"")] + +public class TestFunctions +{{ +{methods} +}}"; + } + + private static string GenerateSourceWithTags(List tags) + { + var methods = string.Join("\n", tags.Select((tag, index) => $@" + [LambdaFunction] + [HttpApi(LambdaHttpMethod.Get, ""/endpoint{index}"")] + [OpenApiTag(""{tag}"")] + public string GetEndpoint{index}() => ""test"";")); + + var tagDefinitions = string.Join("\n", tags.Select(tag => + $@"[assembly: OpenApiTagDefinition(""{tag}"", Description = ""Description for {tag}"")]")); + + return $@" +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Oproto.Lambda.OpenApi.Attributes; + +[assembly: OpenApiInfo(""Test API"", ""1.0.0"")] +{tagDefinitions} + +public class TestFunctions +{{ +{methods} +}}"; + } + + private static string GenerateSourceWithSchemaProperties(List properties) + { + var props = string.Join("\n", properties.Select(prop => + $@" [OpenApiSchema(Description = ""The {prop}"")] + public string {char.ToUpper(prop[0])}{prop.Substring(1)} {{ get; set; }}")); + + return $@" +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Oproto.Lambda.OpenApi.Attributes; + +[assembly: OpenApiInfo(""Test API"", ""1.0.0"")] + +public class TestModel +{{ +{props} +}} + +public class TestFunctions +{{ + [LambdaFunction] + [HttpApi(LambdaHttpMethod.Post, ""/items"")] + public TestModel CreateItem([FromBody] TestModel model) => model; +}}"; + } + + private static string GenerateSourceWithTagGroups(List groupNames, List tagNames) + { + var tagGroupAttributes = string.Join("\n", groupNames.Select((name, index) => + { + var tagsForGroup = tagNames.Skip(index % tagNames.Count).Take(2).ToList(); + var tagsParam = string.Join(", ", tagsForGroup.Select(t => $@"""{t}""")); + return $@"[assembly: OpenApiTagGroup(""{name}"", {tagsParam})]"; + })); + + var tagDefinitions = string.Join("\n", tagNames.Select(tag => + $@"[assembly: OpenApiTagDefinition(""{tag}"", Description = ""Description for {tag}"")]")); + + return $@" +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Oproto.Lambda.OpenApi.Attributes; + +[assembly: OpenApiInfo(""Test API"", ""1.0.0"")] +{tagDefinitions} +{tagGroupAttributes} + +public class TestFunctions +{{ + [LambdaFunction] + [HttpApi(LambdaHttpMethod.Get, ""/items"")] + [OpenApiTag(""{tagNames.First()}"")] + public string GetItems() => ""test""; +}}"; + } + } + + /// + /// **Feature: deterministic-output, Property: Generator Deterministic Output** + /// Test that generating the same source twice produces identical output. + /// **Validates: Requirements 1.1, 2.1, 3.1, 4.1, 5.1, 7.1, 8.1, 9.1, 11.1** + /// + [Property(MaxTest = 100)] + public Property Generator_ProducesIdenticalOutput_ForSameSource() + { + return Prop.ForAll( + GeneratorTestData.SourceWithMultiplePathsGen().ToArbitrary(), + source => + { + var json1 = GenerateAndExtractJson(source); + var json2 = GenerateAndExtractJson(source); + + if (string.IsNullOrEmpty(json1) || string.IsNullOrEmpty(json2)) + return false.Label("Failed to generate JSON"); + + return (json1 == json2) + .Label($"Generated JSON should be identical across runs"); + }); + } + + /// + /// **Feature: deterministic-output, Property: Path Ordering in Generator** + /// For any source with multiple paths, the generated output SHALL have paths in alphabetical order. + /// **Validates: Requirements 1.1** + /// + [Property(MaxTest = 100)] + public Property Generator_SortsPaths_Alphabetically() + { + return Prop.ForAll( + GeneratorTestData.SourceWithMultiplePathsGen().ToArbitrary(), + source => + { + var json = GenerateAndExtractJson(source); + if (string.IsNullOrEmpty(json)) + return false.Label("Failed to generate JSON"); + + var paths = ExtractPaths(json); + if (paths.Count < 2) + return false.Label("Not enough paths generated"); + + var isSorted = paths.SequenceEqual(paths.OrderBy(p => p, StringComparer.Ordinal)); + return isSorted.Label("Paths should be in alphabetical order"); + }); + } + + /// + /// **Feature: deterministic-output, Property: Tag Ordering in Generator** + /// For any source with multiple tags, the generated output SHALL have tags in alphabetical order. + /// **Validates: Requirements 4.1** + /// + [Property(MaxTest = 100)] + public Property Generator_SortsTags_Alphabetically() + { + return Prop.ForAll( + GeneratorTestData.SourceWithMultipleTagsGen().ToArbitrary(), + source => + { + var json = GenerateAndExtractJson(source); + if (string.IsNullOrEmpty(json)) + return false.Label("Failed to generate JSON"); + + var tags = ExtractTags(json); + if (tags.Count < 2) + return false.Label("Not enough tags generated"); + + var isSorted = tags.SequenceEqual(tags.OrderBy(t => t, StringComparer.Ordinal)); + return isSorted.Label("Tags should be in alphabetical order"); + }); + } + + /// + /// **Feature: deterministic-output, Property: Schema Property Ordering in Generator** + /// For any source with schemas containing multiple properties, the generated output SHALL have + /// properties in alphabetical order. + /// **Validates: Requirements 3.1** + /// + [Property(MaxTest = 100)] + public Property Generator_SortsSchemaProperties_Alphabetically() + { + return Prop.ForAll( + GeneratorTestData.SourceWithSchemaPropertiesGen().ToArbitrary(), + source => + { + var json = GenerateAndExtractJson(source); + if (string.IsNullOrEmpty(json)) + return false.Label("Failed to generate JSON"); + + var properties = ExtractSchemaProperties(json, "TestModel"); + if (properties.Count < 2) + return true.Label("Not enough properties to verify ordering (may be expected)"); + + var isSorted = properties.SequenceEqual(properties.OrderBy(p => p, StringComparer.Ordinal)); + return isSorted.Label("Schema properties should be in alphabetical order"); + }); + } + + /// + /// **Feature: deterministic-output, Property: Tag Group Ordering in Generator** + /// For any source with tag groups, the generated output SHALL have tag groups in alphabetical order. + /// **Validates: Requirements 5.1** + /// + [Property(MaxTest = 100)] + public Property Generator_SortsTagGroups_Alphabetically() + { + return Prop.ForAll( + GeneratorTestData.SourceWithTagGroupsGen().ToArbitrary(), + source => + { + var json = GenerateAndExtractJson(source); + if (string.IsNullOrEmpty(json)) + return false.Label("Failed to generate JSON"); + + var tagGroups = ExtractTagGroups(json); + if (tagGroups.Count < 2) + return false.Label("Not enough tag groups generated"); + + var groupNames = tagGroups.Select(g => g.Name).ToList(); + var isSorted = groupNames.SequenceEqual(groupNames.OrderBy(g => g, StringComparer.Ordinal)); + + // Also verify tags within groups are sorted + var tagsWithinGroupsSorted = tagGroups.All(g => + g.Tags.SequenceEqual(g.Tags.OrderBy(t => t, StringComparer.Ordinal))); + + return isSorted.Label("Tag groups should be in alphabetical order") + .And(tagsWithinGroupsSorted).Label("Tags within groups should be in alphabetical order"); + }); + } + + private string GenerateAndExtractJson(string source) + { + try + { + var compilation = CompilerHelper.CreateCompilation(source); + var generator = new OpenApiSpecGenerator(); + + var driver = CSharpGeneratorDriver.Create(generator); + driver.RunGeneratorsAndUpdateCompilation(compilation, + out var outputCompilation, + out var diagnostics); + + if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error)) + return string.Empty; + + return ExtractOpenApiJson(outputCompilation); + } + catch + { + return string.Empty; + } + } + + private string ExtractOpenApiJson(Compilation outputCompilation) + { + var generatedFile = outputCompilation.SyntaxTrees + .FirstOrDefault(x => x.FilePath.EndsWith("OpenApiOutput.g.cs")); + + if (generatedFile == null) + return string.Empty; + + var generatedContent = generatedFile.GetRoot().GetText().ToString(); + + var attributeStart = generatedContent.IndexOf("[assembly: OpenApiOutput(@\"") + 26; + var attributeEnd = generatedContent.LastIndexOf("\", \"openapi.json\")]"); + + if (attributeStart < 26 || attributeEnd < 0) + return string.Empty; + + var rawJson = generatedContent[attributeStart..attributeEnd]; + + return rawJson + .Replace("\"\"", "\"") + .Replace("\r\n", " ") + .Replace("\n", " ") + .Replace("\r", " ") + .Replace("\\\"", "\"") + .Trim() + .TrimStart('"') + .TrimEnd('"'); + } + + private List ExtractPaths(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("paths", out var pathsElement)) + { + return pathsElement.EnumerateObject() + .Select(p => p.Name) + .ToList(); + } + } + catch { } + return new List(); + } + + private List ExtractTags(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("tags", out var tagsElement)) + { + return tagsElement.EnumerateArray() + .Select(t => t.GetProperty("name").GetString()) + .Where(n => n != null) + .ToList()!; + } + } + catch { } + return new List(); + } + + private List ExtractSchemaProperties(string json, string schemaName) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("components", out var components) && + components.TryGetProperty("schemas", out var schemas) && + schemas.TryGetProperty(schemaName, out var schema) && + schema.TryGetProperty("properties", out var properties)) + { + return properties.EnumerateObject() + .Select(p => p.Name) + .ToList(); + } + } + catch { } + return new List(); + } + + private List<(string Name, List Tags)> ExtractTagGroups(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("x-tagGroups", out var tagGroupsElement)) + { + return tagGroupsElement.EnumerateArray() + .Select(g => ( + Name: g.GetProperty("name").GetString() ?? "", + Tags: g.GetProperty("tags").EnumerateArray() + .Select(t => t.GetString() ?? "") + .ToList() + )) + .ToList(); + } + } + catch { } + return new List<(string, List)>(); + } +} diff --git a/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs b/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs index 8a24a58..b51ab55 100644 --- a/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs +++ b/Oproto.Lambda.OpenApi.Tests/TagGroupGenerationPropertyTests.cs @@ -48,14 +48,15 @@ public Property TagGroupAttribute_ParsesNameAndTags() } /// - /// **Feature: tag-groups-extension, Property 2: Tag group order preservation** - /// **Validates: Requirements 1.3, 2.4** + /// **Feature: tag-groups-extension, Property 2: Tag group deterministic ordering** + /// **Validates: Requirements 5.1, 5.2 (deterministic-output)** /// /// For any sequence of OpenApiTagGroupAttribute attributes applied to an assembly, - /// the Source_Generator SHALL output the tag groups in the same order as they are defined. + /// the Source_Generator SHALL output the tag groups in alphabetical order by group name + /// for deterministic output. /// [Property(MaxTest = 100)] - public Property TagGroupAttributes_PreserveOrder() + public Property TagGroupAttributes_AlphabeticalOrder() { // Generate 2-4 distinct group names var groupNamesGen = Gen.ListOf(Gen.Elements("Group A", "Group B", "Group C", "Group D", "Group E")) @@ -72,19 +73,20 @@ public Property TagGroupAttributes_PreserveOrder() if (extractedGroups.Count != groupNames.Count) return false.Label($"Expected {groupNames.Count} groups, but got {extractedGroups.Count}"); - // Check order is preserved - var orderPreserved = true; - for (int i = 0; i < groupNames.Count; i++) + // Check groups are in alphabetical order (deterministic output requirement) + var expectedOrder = groupNames.OrderBy(n => n, StringComparer.Ordinal).ToList(); + var orderCorrect = true; + for (int i = 0; i < expectedOrder.Count; i++) { - if (extractedGroups[i].Name != groupNames[i]) + if (extractedGroups[i].Name != expectedOrder[i]) { - orderPreserved = false; + orderCorrect = false; break; } } - return orderPreserved - .Label($"Expected order [{string.Join(", ", groupNames)}], " + + return orderCorrect + .Label($"Expected alphabetical order [{string.Join(", ", expectedOrder)}], " + $"but got [{string.Join(", ", extractedGroups.Select(g => g.Name))}]"); }); }