diff --git a/.squad/agents/fenster/history.md b/.squad/agents/fenster/history.md index 6307c627..528f3460 100644 --- a/.squad/agents/fenster/history.md +++ b/.squad/agents/fenster/history.md @@ -49,6 +49,24 @@ Format REQUIRED before commit: `dotnet format src/Refitter.slnx` - `CSharpClientGeneratorFactory` has two near-identical recursive schema traversal methods: `FixMissingTypesWithIntegerFormat` and `ApplyCustomIntegerType` — should share a generic schema visitor. - `RefitGenerator.GenerateClient` single-file path returns an array where only the first item has content, remaining items have empty strings — subtle and confusing to future maintainers. +--- + +## Issue #967 — Property Naming Implementation (2026-03-25) + +**Status:** ✅ DELIVERED & APPROVED + +Implemented property naming policy feature across product surfaces: +- Created `PropertyNamingPolicy` enum (`PascalCase`, `PreserveOriginal`) on `RefitGeneratorSettings` +- Built `PreserveOriginalPropertyNameGenerator` with edge-case handling (reserved keywords, invalid identifiers) +- Exposed via CLI (`--property-naming-policy`), `.refitter` file, and updated schema +- All validation gates pass: build, 1468 tests, format + +**Collaborators:** +- Hockney provided comprehensive regression coverage +- Keaton reviewed and approved for merge + +**Known Follow-up:** IdentifierUtils contains three public methods with zero external callers (Keaton flagged as non-blocking; consolidate or remove in next PR). + #### CLI Option Gaps - `GenerateAuthenticationHeader` — present in `RefitGeneratorSettings`, NO CLI option in `Settings.cs`. - `AddContentTypeHeaders` — `AddAcceptHeaders` has `--no-accept-headers` but `AddContentTypeHeaders` has NO CLI option. @@ -124,3 +142,154 @@ Successfully implemented fix for non-ASCII characters in XML status-code comment - Tests confirmed: Cyrillic Unicode renders correctly, escape sequences absent, compilation succeeds - All 1415 tests pass with no regressions +### Issue #967 Investigation — Snake_case Property Names — 2026-03-06 + +**Feasibility: TECHNICALLY FEASIBLE, requires product surface changes** + +Investigated whether Refitter can generate snake_case C# property names instead of mandatory PascalCase conversion. + +**Key Findings:** + +1. **Current Access Paths:** + - NOT accessible via CLI (no `--property-name-generator` option) + - NOT accessible via `.refitter` files (`CodeGeneratorSettings.PropertyNameGenerator` marked `[JsonIgnore]`) + - NOT accessible via source generator + - Only accessible programmatically by injecting custom `IPropertyNameGenerator` into core settings + +2. **Root Cause:** + - `CustomCSharpPropertyNameGenerator` is hardcoded in `CSharpClientGeneratorFactory.Create()` line 33 + - Performs mandatory `ConvertToUpperCamelCase()` + `ConvertSnakeCaseToPascalCase()` (line 46) + - Falls back to this generator when user doesn't provide custom one + - `CodeGeneratorSettings.PropertyNameGenerator` explicitly marked `[JsonIgnore]` to prevent `.refitter` deserialization + - Historical #516 added support for custom generators but never exposed to end users + +3. **Edge Cases Requiring Handling:** + - Invalid C# identifiers (spaces → `_`, hyphens → `_`, leading digits → prepend `_`) + - C# reserved keywords (detect & add `@` prefix or `_` suffix) + - **Critical:** `[JsonPropertyName]` attribute MUST still be generated for deserialization to work; user's example omitted it, would require `PropertyNameCaseInsensitive = true` to avoid binding failures + - Potential name collisions when sanitization produces duplicates + +4. **Schema/Serialization Complexity:** + - `IPropertyNameGenerator` is an external NJsonSchema interface, difficult to expose via JSON without type discriminator + - Creating polymorphic deserialization for `.refitter` files would require either whitelist strategy or reflection-based instantiation + - Recommenda­tion: CLI-only implementation to avoid schema complexity + +5. **Product Surfaces Requiring Changes (if approved):** + - Add `--use-property-name-as-is` CLI option to `Settings.cs` + - Map option in `GenerateCommand.cs` to inject `PreservingPropertyNameGenerator` + - Create `PreservingPropertyNameGenerator` in core (sanitizes invalid identifiers & reserved keywords) + - Update README.md with new option and behavior description + +**Full detailed feasibility report written to `.squad/decisions/inbox/fenster-issue-967-investigation.md`** + + +## 2026-03-25: Issue #967 Team Assessment + +Team consensus reached on GitHub issue #967 (Preserve Original Property Names): +- ✅ APPROVED FOR IMPLEMENTATION +- Recommended surface: CLI option --property-naming-policy with enum values +- Implementation surfaces identified: CLI, settings mapper, new generator class +- Edge-case handling: reserved keywords, hyphens, leading digits, collisions +- No .refitter file support in Phase 1 (defer polymorphic deserialization) + +Consolidated decision entry created in decisions.md. See orchestration logs for full team assessment. + +### Issue #967 Implementation Planning — 2026-03-25 + +Completed comprehensive implementation plan (`.squad/implementation-plan-issue-967.md`) covering: + +**Recommended Implementation Shape:** +- CLI surface: `--property-naming-policy [PascalCase|Preserve|CamelCase|SnakeCase]` +- Architecture: Pluggable `IPropertyNameGenerator` hierarchy; factory-based routing +- Scope: Phase 1 = CLI only; Phase 2 = .refitter/.refitter files (defer polymorphic JSON deserialization) +- Default: PascalCase (backward compatible) + +**File Changes (13 tasks, ~5 hours estimated):** + +**New Files (5):** +1. `PropertyNamingPolicy.cs` — Enum (PascalCase, Preserve, CamelCase, SnakeCase) +2. `IdentifierUtils.cs` — Shared validation: `IsValidIdentifier()`, `SanitizeInvalidIdentifier()`, `EscapeReservedKeyword()` +3. `PreservingPropertyNameGenerator.cs` — Generator: use exact OpenAPI names, sanitize invalid identifiers +4. `CamelCasePropertyNameGenerator.cs` — Generator (optional MVP) +5. `PropertyNamingPolicyTests.cs` — 10+ test cases per SKILL validation checklist + +**Modified Files (6):** +1. `src/Refitter/Settings.cs` — Add `[CommandOption("--property-naming-policy")]` +2. `src/Refitter/GenerateCommand.cs` — Map setting in `CreateRefitGeneratorSettings()` +3. `src/Refitter.Core/Settings/RefitGeneratorSettings.cs` — Add property + serialization +4. `src/Refitter.Core/CSharpClientGeneratorFactory.cs` — Update line 33 generator routing logic +5. `README.md` — Document new option with examples +6. `docs/json-schema.json` — Add `propertyNamingPolicy` definition + +**Work Breakdown (Ordered):** + +*Phase 1: Core Infrastructure* (1 hour) +- Task 1.1: PropertyNamingPolicy enum (5 min) +- Task 1.2: IdentifierUtils sanitization (30 min) +- Task 1.3: PreservingPropertyNameGenerator (15 min) +- Task 1.4: CamelCasePropertyNameGenerator (15 min, optional) + +*Phase 2: Settings & Routing* (45 min) +- Task 2.1: RefitGeneratorSettings property (10 min) +- Task 2.2: CSharpClientGeneratorFactory routing (15 min) +- Task 2.3: Settings.cs CLI option (5 min) +- Task 2.4: GenerateCommand mapper (5 min) + +*Phase 3: Testing* (2.5 hours) +- Task 3.1: PropertyNamingPolicyTests (10+ tests, 2 hours) +- Task 3.2: Regression validation (15 min) + +*Phase 4: Documentation* (1 hour) +- Task 4.1: README updates (30 min) +- Task 4.2: json-schema.json (15 min) +- Task 4.3: Format validation (5 min) + +**Critical Edge Cases & Mitigations:** + +| Case | Risk | Mitigation | +|------|------|-----------| +| Hyphens (`pay-method`) | Invalid C# | Reject in Preserve mode + clear error | +| Reserved keywords (`class`) | Compilation error | Auto-escape → `@class` | +| Leading digits (`123abc`) | Invalid C# | Prepend underscore → `_123abc` | +| Collisions (`Class`, `_class`) | Silent errors | Detect + warn; use sequential suffix | +| No `[JsonPropertyName]` | Deserialization fails | Always emit regardless of mode | + +**Test Strategy:** +- 5+ core unit tests: basic, PascalCase regression, invalid handling, keyword escaping, compilation +- Integration: CLI help, generation, serialization roundtrip +- Regression: Full 1415+ test suite must pass + +**Success Criteria:** +- ✅ CLI option functional and documented +- ✅ Preserve mode generates exact property names (snake_case example) +- ✅ All edge cases handled safely +- ✅ 10+ unit tests pass; 0 regressions +- ✅ Code compiles; serialization verified +- ✅ Format validation passes + +**Phase 2 (Deferred):** +- `.refitter` file support: Add `propertyNamingPolicy` to schema; implement enum-based factory in SourceGenerator/MSBuild +- Complexity: JSON polymorphic deserialization deferred; enum routing sufficient + +**Key Design Decisions Ratified:** +1. CLI-only Phase 1 (simpler than polymorphic .refitter deserialization) +2. Enum-based routing (cleaner than interface discriminator pattern) +3. Reject invalid identifiers in Preserve mode (safer UX) +4. Use `@` prefix for reserved keywords (standard C# pattern) +5. Default to PascalCase (backward compatible) +6. Always emit `[JsonPropertyName]` (ensures correct binding) + +**Documentation Plan:** +- README: Add "Property Naming Policies" section with use cases for each enum value +- Examples: PascalCase vs Preserve side-by-side output +- Schema: Note that `[JsonPropertyName]` is auto-generated for binding + +**Implementation Ready:** ✅ Plan complete, approved by team, ready for Phase 1 execution. + +### Issue #967 Implementation — 2026-03-25 + +- Added a top-level `PropertyNamingPolicy` enum to `RefitGeneratorSettings` and wired it through the CLI, `.refitter` JSON surface, Source Generator, and MSBuild/shared serializer flow with `PascalCase` as the default. +- `CSharpClientGeneratorFactory` now resolves property name generation by precedence: programmatic `CodeGeneratorSettings.PropertyNameGenerator` first, then the user-facing `PropertyNamingPolicy`. +- `PreserveOriginalPropertyNameGenerator` preserves valid identifiers, escapes reserved C# keywords with `@`, minimally sanitizes invalid shapes with underscores, and de-duplicates sibling collisions with `IdentifierUtils.Counted` against `ParentSchema.Properties`. +- Removed the dead `propertyNameGenerator` JSON schema surface and documented the replacement setting in `README.md`. +- Validation that succeeded on this slice: CLI `--help` shows `--property-naming-policy`; direct CLI generation and `.refitter` generation both preserved raw property names and kept `[JsonPropertyName]`; full repo gate passed with `dotnet build -c Release src\Refitter.slnx`, `dotnet test -c Release --solution src\Refitter.slnx`, and `dotnet format --verify-no-changes src\Refitter.slnx`. diff --git a/.squad/agents/hockney/history.md b/.squad/agents/hockney/history.md index 6c31cd2b..daf06e31 100644 --- a/.squad/agents/hockney/history.md +++ b/.squad/agents/hockney/history.md @@ -28,3 +28,69 @@ Added regression test coverage for non-ASCII XML documentation fix: - Unit test: XmlDocumentationGeneratorTests.cs validates Unicode preservation and escape sequence decoding - Integration test: GenerateStatusCodeCommentsTests.cs end-to-end spec→generation→compilation check - Tests confirm fix works across full pipeline; all 1415 tests pass + +### 2026 Investigation — Issue #967 Raw Property Name Support + +**Findings:** Raw snake_case property names (e.g., `payMethod_SumBank`) are **valid C# and safe with System.Text.Json**. Compiled and tested standalone console app confirming deserialization/serialization work correctly. + +**Current State:** Feature infrastructure exists (`CodeGeneratorSettings.PropertyNameGenerator` property, NJsonSchema integration in `CSharpClientGeneratorFactory`) but is NOT exposed to users: +1. No CLI option `--property-name-generator` +2. JSON schema still lists `propertyNameGenerator` despite `[JsonIgnore]` in code +3. SerializerTests explicitly exclude PropertyNameGenerator from round-trip validation + +**Safety Assessment:** +- ✅ C# compilation: Valid identifiers, no syntax errors +- ✅ System.Text.Json: Correctly maps via `[JsonPropertyName]` attribute +- ⚠️ Edge cases: Hyphens/invalid chars would break (need validation), reserved keywords require `@` prefix +- ⚠️ Config consistency: PropertyNameGenerator field marked `[JsonIgnore]`, so custom generators won't serialize + +**Test Design Recommendations (if feature enabled):** +- Can_Generate_Code_With_Raw_Property_Names() +- Generated_Code_Uses_Raw_Property_Names (assertion: no PascalCase) +- Can_Build_Generated_Code_With_Raw_Names + +--- + +## Issue #967 — Test Coverage Implementation (2026-03-25) + +**Status:** ✅ DELIVERED & APPROVED + +Added comprehensive regression coverage for property naming feature: +- Core unit tests: PascalCase regression, PreserveOriginal identifiers, keyword escaping, invalid identifier sanitization, compilation checks +- CLI binding: `--property-naming-policy` argument parsing +- Serializer round-trip: Settings deserialization from JSON +- Settings defaults: `PropertyNamingPolicy.PascalCase` verified +- Source Generator end-to-end: `.refitter` file generation matches CLI output + +**Test Coverage:** 1468/1468 tests pass (4 issue #967 test files, 4 issue #967 test classes, 30+ test cases) + +**Collaborators:** +- Fenster built the implementation +- Keaton reviewed and approved for merge +- Serialization_Preserves_Mapping_With_Raw_Names (JSON roundtrip) +- Invalid_Property_Names_Are_Rejected (hyphens, spaces, etc.) + +**Feasibility:** 8.5 hours to fully expose (CLI option, tests, documentation). Feature is technically proven safe; blockers are purely user-exposure and validation design decisions. + +## 2026-03-25: Issue #967 Team Assessment + +Team consensus reached on GitHub issue #967 (Preserve Original Property Names): +- ✅ APPROVED FOR IMPLEMENTATION +- Safety verdict confirmed: Raw property names compile safely with System.Text.Json +- Recommended test coverage: reserved keywords, invalid identifiers, build success, serialization round-trip +- Edge cases requiring mitigation: hyphens, leading digits, name collisions +- Sanitization strategy: preserve casing/underscores, validate C# identifier legality + +Consolidated decision entry created in decisions.md. See orchestration logs for full team assessment. + +### 2026-03-25 — Issue #967 Regression Coverage Executed + +Implemented end-to-end regression coverage for the shipped `PropertyNamingPolicy` surface: +- `src\Refitter.Tests\Examples\PropertyNamingPolicyTests.cs` verifies default PascalCase output, `PreserveOriginal` raw valid identifiers, reserved keyword escaping, invalid identifier sanitization, and `BuildHelper.BuildCSharp()` compilation. +- `SerializerTests`, `SettingsTests`, and `GenerateCommandTests` now cover enum serialization plus CLI/settings binding for `PropertyNamingPolicy`. +- Source Generator parity is covered with `src\Refitter.SourceGenerator.Tests\AdditionalFiles\PropertyNamingPolicy.refitter` and reflection-based assertions over the generated `PaymentResponse` contract. + +**Landed behavior worth remembering:** the preserving generator keeps valid identifiers as-is, prefixes reserved keywords with `@`, and minimally sanitizes invalid names by replacing invalid characters with `_` and prefixing invalid starts with `_`. + +**Validation:** `dotnet build -c Release src\Refitter.slnx`, `dotnet test --solution src\Refitter.slnx -c Release --no-build`, and `dotnet format --verify-no-changes src\Refitter.slnx` all passed; full suite count reached 1468 tests. + diff --git a/.squad/agents/keaton/history.md b/.squad/agents/keaton/history.md index 91f7f41d..250f68ec 100644 --- a/.squad/agents/keaton/history.md +++ b/.squad/agents/keaton/history.md @@ -48,6 +48,30 @@ Build: `dotnet build -c Release src/Refitter.slnx` (~22s). Tests: `dotnet test - - `partial interface` generation allows user extension. **Architectural concerns identified:** + +--- + +## Issue #967 — Review & Approval (2026-03-25) + +**Status:** ✅ APPROVED FOR MERGE + +Reviewed implementation, validated test coverage, approved feature for PR creation. + +**Validation:** All three gates pass — build (0 errors), tests (1468/1468), format (clean) + +**Findings:** +- Feature correctness verified: PreserveOriginalPropertyNameGenerator handles valid identifiers, reserved keywords, invalid chars correctly +- Test coverage is comprehensive across CLI, serializer, settings, and Source Generator +- Root-caused previous test failures: `dotnet test` CLI syntax (positional solution arg → `--solution` flag), not code defect + +**Known Follow-up (non-blocking):** +- IdentifierUtils contains three public methods with zero external callers +- PreserveOriginalPropertyNameGenerator reimplements equivalent logic +- Action: Consolidate or remove in follow-up PR, assign to Fenster + +**Collaborators:** +- Fenster delivered implementation +- Hockney provided test coverage 1. **SourceGenerator sync-over-async:** `RefitGenerator.CreateAsync(...).GetAwaiter().GetResult()` in source generator can deadlock in certain IDE hosts. 2. **Duplicated source compilation:** SourceGenerator includes Core `.cs` files via `` rather than IL reference — changes to Core files compile twice and can drift. 3. **MSBuild JSON parsing with regex:** `RefitterGenerateTask` uses regex to parse JSON config (`ExtractJsonStringValue`/`ExtractJsonBoolValue`) instead of a JSON parser, which is fragile. @@ -116,3 +140,57 @@ Added comprehensive regression testing for non-ASCII XML documentation fix: - Unit test in `XmlDocumentationGeneratorTests.cs` validates readable Unicode and absence of `\uXXXX` escape sequences - Integration test in `GenerateStatusCodeCommentsTests.cs` confirms full pipeline compilation success with Unicode content - Tests follow existing pattern: direct assertion + compilation verification + +### Issue #967 Feasibility — Preserve Original Property Names (2025) + +**Investigated** whether users can preserve raw OpenAPI property names (e.g., `payMethod_SumBank`) instead of PascalCase conversion (`PayMethodSumBank`). + +**Key findings:** +- **Not possible today** from CLI or `.refitter` config. `CustomCSharpPropertyNameGenerator` always PascalCases via `ConvertToUpperCamelCase` + `ConvertSnakeCaseToPascalCase`. +- **Possible programmatically** — `CSharpClientGeneratorFactory.cs:33` respects `settings.CodeGeneratorSettings.PropertyNameGenerator` if provided, but `[JsonIgnore]` on the property blocks all config-file users. +- **Valid C# with correct serialization** — underscores are legal in C# identifiers; NSwag already emits `[JsonPropertyName]` attributes. +- **Recommended approach:** New `PropertyNamingPolicy` enum setting (PascalCase/PreserveOriginal) on `RefitGeneratorSettings` + new passthrough `IPropertyNameGenerator` implementation. +- **Schema inconsistency:** `docs/json-schema.json` exposes `propertyNameGenerator` with `$ref: IPropertyNameGenerator` but the C# property is `[JsonIgnore]` — dead surface that misleads IDE users. + +**Key files:** +- `CustomCSharpPropertyNameGenerator.cs` — current PascalCase conversion logic +- `StringCasingExtensions.cs:70-79` — `ConvertSnakeCaseToPascalCase` implementation +- `CSharpClientGeneratorFactory.cs:33` — generator selection with fallback +- `CodeGeneratorSettings.cs:265` — `[JsonIgnore]` on `PropertyNameGenerator` +- `docs/json-schema.json:193,251` — misleading schema entries + +## 2026-03-25: Issue #967 Team Assessment + +Team consensus reached on GitHub issue #967 (Preserve Original Property Names): +- ✅ APPROVED FOR IMPLEMENTATION +- Product shape: PropertyNamingPolicy enum (PascalCase, PreserveOriginal) +- Implementation effort: 6-7 hours +- No breaking changes +- Safety verdict: Safe with proper edge-case testing + +Consolidated decision entry created in decisions.md. See orchestration logs for full team assessment. + +### Issue #967 PRD Authoring — 2026-03-25 + +Drafted comprehensive PRD for the "Preserve Original Property Names" feature. Key learnings: + +- **Enum placement matters:** `PropertyNamingPolicy` belongs on `RefitGeneratorSettings` (top-level), not nested in `CodeGeneratorSettings`, because it controls a Refitter-specific behavioral choice, not an NSwag passthrough setting. The `CodeGeneratorSettings.PropertyNameGenerator` (`[JsonIgnore]`) is the implementation mechanism, not the user-facing config. +- **`.refitter` support is trivial for enums:** Previous team assessment suggested deferring `.refitter` file support due to polymorphic serialization concerns. This was overcautious — `PropertyNamingPolicy` is a simple enum, not an `IPropertyNameGenerator` interface. Standard `System.Text.Json` enum deserialization handles it. All three surfaces (CLI, `.refitter`, JSON schema) can ship together. +- **Sanitize-not-reject for edge cases:** Rejecting invalid property names (hyphens, spaces) would be a breaking behavior change for specs that work today with PascalCase. The safer approach is minimal sanitization (replace with underscore) so the feature adds capability without removing it. +- **`[JsonPropertyName]` must always emit:** Even when the C# property name matches the JSON key, the attribute should still be present for correctness — NSwag already does this, so no change needed, but the PRD must call it out to prevent future "optimization" that removes "redundant" attributes. + +### Issue #967 Implementation Review — 2026-07-09 + +**Reviewed and APPROVED** the full implementation of issue #967 (Preserve Original Property Names). All three PR gates pass: build (0 errors, 0 warnings), tests (1468/1468 pass), format (clean). + +**Previous non-zero test exits:** Most likely caused by the `dotnet test` CLI syntax change — positional solution argument now requires `--solution` flag. Running `dotnet test -c Release src/Refitter.slnx` exits 1 without executing any tests. The correct invocation is `dotnet test -c Release --solution src/Refitter.slnx`. + +**Implementation fully aligns with agreed plan.** All five phases are complete: +- Phase 2 (Core): `PropertyNamingPolicy` enum, `PreserveOriginalPropertyNameGenerator`, `CSharpClientGeneratorFactory` wiring with correct precedence (programmatic override → policy → default). +- Phase 3 (User-facing): `--property-naming-policy` CLI option, GenerateCommand mapping, JSON schema updated (stale `propertyNameGenerator`/`IPropertyNameGenerator` removed, new `propertyNamingPolicy` enum added). +- Phase 4 (Tests): SerializerTests, SettingsTests, GenerateCommandTests, PropertyNamingPolicyTests (core + source generator). Source generator tests validate full pipeline through `.refitter` → compilation → reflection. +- Phase 5 (Docs): README updated with examples, `.refitter` format, and reference docs. + +**Follow-up recommendation (non-blocking):** +- `IdentifierUtils` has three new public methods (`ToCompilableIdentifier`, `IsValidIdentifier`, `EscapeReservedKeyword`) with zero external callers — dead code. `PreserveOriginalPropertyNameGenerator` reimplements the same logic with a broader keyword list (130 vs 78 keywords). Future cleanup should either refactor the generator to delegate to `IdentifierUtils` (merging keyword lists) or remove the unused methods. + diff --git a/.squad/commit-message.txt b/.squad/commit-message.txt new file mode 100644 index 00000000..1e9da013 --- /dev/null +++ b/.squad/commit-message.txt @@ -0,0 +1,11 @@ +Issue #967 team assessment: Preserve Original Property Names + +Keaton (Architect): Feasibility confirmed. Recommend PropertyNamingPolicy enum. +Fenster (.NET Dev): Implementation path identified. CLI-only Phase 1, defer .refitter. +Hockney (Tester): Safety verified. Raw property names compile; edge-case tests required. + +Consensus: APPROVED FOR IMPLEMENTATION. 6-7 hour effort, zero breaking changes. + +Orchestration logs: 2026-03-25T08-07-15Z-{keaton,fenster,hockney}.md +Session log: 2026-03-25T08-07-15Z-issue-967-feasibility.md +Decision entry: decisions.md#9 (consolidated from inbox, inbox files deleted) diff --git a/.squad/decisions.md b/.squad/decisions.md index beb2bb9b..885d7ae2 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -354,4 +354,237 @@ Commit changes in small logical groups with one-liner commit messages without a --- +### 9. Issue #967 — Preserve Original Property Names (2026-03-25) + +**Status:** ✅ TEAM CONSENSUS — APPROVED FOR IMPLEMENTATION +**Date:** 2026-03-25 +**Assessors:** Keaton (Architect), Fenster (.NET Dev), Hockney (Tester) + +#### Problem Statement + +User requests the ability to generate C# properties that preserve the original OpenAPI property name (e.g., `payMethod_SumBank`) instead of converting to PascalCase (`PayMethodSumBank`). + +**Current output:** +```csharp +[System.Text.Json.Serialization.JsonPropertyName("payMethod_SumBank")] +public double? PayMethodSumBank { get; set; } +``` + +**Desired output:** +```csharp +public double? payMethod_SumBank { get; set; } +``` + +#### Key Findings + +**Keaton (Architect):** +- ✅ Architecture supports pluggable `IPropertyNameGenerator` at factory level +- ❌ Property generation hardcoded; users cannot access without code changes +- 🔧 Recommended shape: `PropertyNamingPolicy` enum (`PascalCase`, `PreserveOriginal`) +- Effort: 1-2 days + +**Fenster (.NET Dev):** +- 📍 Hardcoded in `CustomCSharpPropertyNameGenerator.cs:45-47` +- ❌ No CLI option; `.refitter` file blocked by `[JsonIgnore]` +- 🔨 Required surfaces: CLI option, settings mapping, new generator class +- Recommendation: CLI-only initially (defer `.refitter` support due to polymorphic deserialization complexity) +- Effort: 4-6 hours + +**Hockney (Tester):** +- ✅ C# compilation safe; `payMethod_SumBank` is valid identifier +- ✅ System.Text.Json works correctly with `[JsonPropertyName]` attribute +- ⚠️ Edge cases require mitigation: reserved keywords, hyphens, leading digits, name collisions +- 📋 Comprehensive tests needed for edge-case coverage +- Verdict: Safe to implement with proper validation + +#### Recommended Product Shape + +1. **New enum:** `PropertyNamingPolicy` on `RefitGeneratorSettings` + - Values: `PascalCase` (default), `PreserveOriginal` + +2. **New generator class:** `PreserveOriginalPropertyNameGenerator : IPropertyNameGenerator` + - Sanitizes invalid C# identifiers only; preserves casing/underscores + - Handles reserved keywords (e.g., `@class`) + - Validates against name collisions + +3. **CLI option:** `--property-naming-policy` + - Example: `--property-naming-policy PreserveOriginal` + +4. **Edge-case handling:** + - Reserved keywords → add `@` prefix or suffix + - Spaces/hyphens → replace with underscore + - Leading digits → prepend underscore + - Name collisions → validation error + +5. **Testing:** + - Unit tests for `PreserveOriginalPropertyNameGenerator` (reserved keywords, invalid identifiers) + - Integration tests (CLI generation + code compilation) + - Serialization round-trip validation + +6. **Documentation:** + - Update README with new CLI option + - Update JSON schema + - Clarify that `[JsonPropertyName]` still generated for correct deserialization + +#### Implementation Roadmap + +| Phase | Task | Effort | Dependencies | +|-------|------|--------|---| +| Phase 1 | Create `PreserveOriginalPropertyNameGenerator` | 1h | None | +| | Add CLI option `--property-naming-policy` | 1h | None | +| | Wire settings mapping | 1h | Phase 1 | +| Phase 2 | Add unit tests (edge cases) | 1-2h | Phase 1 | +| | Add integration tests | 1h | Phase 1 | +| Phase 3 | Update README + schema | 1h | Phase 2 | +| **Total** | | **6-7h** | | + +#### Future Work (Deferred) + +- `.refitter` file support via whitelist strategy (requires polymorphic serialization design) +- Schema cleanup: remove dead `propertyNameGenerator` references + +#### Consensus + +✅ **PROCEED WITH IMPLEMENTATION** + +Issue #967 is **technically feasible**, addresses a **real use case** (snake_case JSON APIs), carries **low risk** (no breaking changes), and has **clean product shape** (simple enum). + +--- + *Maintained by Scribe. Agents write to `.squad/decisions/inbox/` and Scribe merges, deduplicates, and consolidates here.* + +--- + +## Issue #967 — prd issue + +# Decision: PRD for Issue #967 — Preserve Original Property Names + +**Author:** Keaton (Lead / Architect) +**Date:** 2026-03-25 +**Status:** PRD DRAFTED — Awaiting coordinator/user confirmation on open questions + +## Decision + +Produced a comprehensive PRD for issue #967. Key architectural decisions embedded: + +1. **Setting location:** New `PropertyNamingPolicy` enum on `RefitGeneratorSettings` (top-level, not nested in `CodeGeneratorSettings`) — consistent with `MultipleInterfaces`, `TypeAccessibility`, `CollectionFormat` pattern. + +2. **Enum values:** `PascalCase` (default) and `PreserveOriginal`. Future-extensible to `camelCase` or `snake_case` if requested. + +3. **Implementation approach:** New `PreserveOriginalPropertyNameGenerator : IPropertyNameGenerator` class, selected in `CSharpClientGeneratorFactory` based on the enum value. + +4. **Edge-case strategy:** Sanitize-not-reject. Invalid C# identifiers (hyphens, spaces, leading digits) get minimal transforms (underscores). Reserved keywords get `@` prefix. This matches NSwag's philosophy. + +5. **Configuration surfaces:** CLI (`--property-naming-policy`), `.refitter` file (`propertyNamingPolicy`), and JSON schema — all three from day one. The previous team assessment suggested deferring `.refitter` support, but since this is a simple enum (not a polymorphic type like `IPropertyNameGenerator`), standard JSON deserialization handles it with zero complexity. + +6. **`[JsonPropertyName]` behavior:** Always emitted regardless of policy. This ensures serialization correctness even when property names match the JSON key. + +## Open Questions for User + +- Should `PreserveOriginal` also skip the reserved-character sanitization (second pass in `CustomCSharpPropertyNameGenerator`), or only skip PascalCase conversion? +- Is `camelCase` a desired third option, or should we stick with two values? +- Should the feature be marked `[Experimental]` in the first release? + +## Impact + +No breaking changes. Default behavior unchanged. All existing tests remain valid. +--- + +## Issue #967 — issue 967 + +# Fenster — Issue #967 Implementation Decision + +## Decision + +Ship property-name preservation as a simple top-level `PropertyNamingPolicy` enum on `RefitGeneratorSettings` with two values only: + +- `PascalCase` (default) +- `PreserveOriginal` + +Keep the existing programmatic `CodeGeneratorSettings.PropertyNameGenerator` override as the highest-precedence escape hatch for library consumers. + +## Why + +- A serializable enum gives immediate parity across CLI, `.refitter`, Source Generator, and MSBuild without introducing polymorphic JSON deserialization. +- Preserving original names safely still needs generator logic, so the implementation should live in core and be selected centrally from `CSharpClientGeneratorFactory`. +- The old `propertyNameGenerator` JSON schema entry was misleading because it advertised a setting users could not actually round-trip from `.refitter` files. + +## Implementation Notes + +1. Added `PropertyNamingPolicy` to the shared settings model and CLI surface. +2. Introduced `PreserveOriginalPropertyNameGenerator` for: + - valid identifiers unchanged, + - reserved keywords escaped with `@`, + - invalid identifier shapes minimally sanitized with underscores, + - sibling collisions de-duplicated via `IdentifierUtils.Counted`. +3. Removed `propertyNameGenerator` from `docs/json-schema.json` and documented `propertyNamingPolicy` in `README.md`. + +## Validation + +- CLI `--help` includes `--property-naming-policy` +- Direct CLI generation preserves raw property names and keeps `[JsonPropertyName]` +- `.refitter` generation preserves raw property names and keeps `[JsonPropertyName]` +- `dotnet build -c Release src\Refitter.slnx` +- `dotnet test -c Release --solution src\Refitter.slnx` +- `dotnet format --verify-no-changes src\Refitter.slnx` +--- + +## Issue #967 — issue 967 + +# Hockney Decision — Issue #967 Regression Coverage + +## Decision + +Treat `PropertyNamingPolicy.PreserveOriginal` test expectations as: + +- preserve already-valid identifiers exactly (for example `payMethod_SumBank`) +- escape reserved C# keywords with `@` (for example `class` → `@class`) +- minimally sanitize invalid identifiers into compilable names by replacing invalid characters with `_` and prefixing invalid starts with `_` (for example `1st-payment-method` → `_1st_payment_method`) + +## Why + +This matches the landed implementation and keeps the generated DTO surface predictable without reintroducing PascalCase rewriting. It also gives stable regression coverage for both compile safety and user-facing naming behavior. + +## Test Strategy + +- text-based generation assertions in `src\Refitter.Tests\Examples\PropertyNamingPolicyTests.cs` +- CLI/settings binding coverage in `SerializerTests`, `SettingsTests`, and `GenerateCommandTests` +- Source Generator parity via `AdditionalFiles\PropertyNamingPolicy.refitter` plus reflection over generated members instead of brittle full-file snapshots +--- + +## Issue #967 — issue 967 + +# Decision: Issue #967 Implementation — APPROVED + +**Date:** 2026-07-09 +**Author:** Keaton (Lead / Architect) +**Status:** Approved + +## Context + +Issue #967 adds `PropertyNamingPolicy` with `PascalCase` (default) and `PreserveOriginal` values, allowing users to preserve original OpenAPI property names in generated contracts. + +## Decision + +Implementation is **approved for merge** pending PR creation. All three gates pass: +- Build: 0 errors, 0 warnings +- Tests: 1468/1468 pass +- Format: clean + +## Findings + +1. **Feature correctness:** Verified. The `PreserveOriginalPropertyNameGenerator` correctly handles valid identifiers, reserved keywords, invalid start characters, and invalid body characters. Programmatic `IPropertyNameGenerator` override takes precedence as designed. + +2. **Test coverage is comprehensive:** Core unit tests (default PascalCase regression, preserved identifiers, keyword escaping, invalid identifier sanitization, compilation check), serializer round-trip, CLI mapping, settings defaults, and source generator end-to-end through `.refitter` file. + +3. **Previous test failures were not caused by code defects.** The `dotnet test` CLI syntax changed — positional solution argument now requires `--solution` flag. + +## Follow-up (non-blocking) + +- **Dead code in IdentifierUtils:** Three new public methods (`ToCompilableIdentifier`, `IsValidIdentifier`, `EscapeReservedKeyword`) have zero external callers. `PreserveOriginalPropertyNameGenerator` reimplements equivalent logic with a broader keyword list. Consolidate or remove in a follow-up PR. Assign to Fenster. + +## Decision: Commit changes in small logical groups +### 2026-03-25T09:23:40Z: User directive +**By:** Christian Helle (via Copilot) +**What:** Commit changes in small logical groups automatically for all agent work. +**Why:** User request — captured for team memory diff --git a/.squad/decisions/fenster-plan-issue-967.md b/.squad/decisions/fenster-plan-issue-967.md new file mode 100644 index 00000000..6108017b --- /dev/null +++ b/.squad/decisions/fenster-plan-issue-967.md @@ -0,0 +1,107 @@ +# Decision: Issue #967 Implementation Plan — Preserve Original Property Names + +**Status:** ✅ DECISION FINALIZED +**Date:** 2026-03-25 +**Owner:** Fenster +**Team Consensus:** Yes (approvals in orchestration log) + +--- + +## Decision Statement + +**Feature:** Allow generated C# contract properties to preserve original OpenAPI property names instead of mandatory PascalCase conversion. + +**Approved Approach:** +1. **CLI Surface:** `--property-naming-policy` enum option with 4 values: `PascalCase` (default), `Preserve`, `CamelCase`, `SnakeCase` +2. **Implementation:** Pluggable `IPropertyNameGenerator` hierarchy; inject correct generator based on policy +3. **Scope (Phase 1):** CLI only; defer `.refitter` file support to Phase 2 (source generator/MSBuild) +4. **Default Behavior:** `PascalCase` (backward compatible, no breaking changes) + +--- + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **CLI-only (Phase 1)** | `.refitter` files require polymorphic deserialization of `IPropertyNameGenerator`; defer to Phase 2 to avoid JSON schema complexity | +| **Enum-based routing** | Simpler than discriminator pattern; aligns with existing settings architecture | +| **Preserve mode rejects invalid identifiers** | Safer than silent sanitization; gives users explicit control; can extend sanitization logic in future | +| **Reserved keywords → `@` prefix** | Standard C# pattern; clearer intent than suffix | +| **Always emit `[JsonPropertyName]`** | Ensures correct serialization mapping regardless of property name choice | +| **Default = PascalCase** | Maintains backward compatibility; opt-in to new modes | + +--- + +## Implementation Boundary + +**In Scope (Phase 1):** +- New CLI option `--property-naming-policy` +- Core property name generators (Preserve, CamelCase) +- C# identifier validation & sanitization utilities +- Unit & integration tests +- README documentation + +**Out of Scope (Phase 2):** +- `.refitter` file support +- Source generator integration +- MSBuild task integration +- Schema evolution for polymorphic `IPropertyNameGenerator` deserialization + +--- + +## Files Changed (Phase 1) + +**New Files (5):** +- `src/Refitter.Core/PropertyNamingPolicy.cs` — Enum +- `src/Refitter.Core/IdentifierUtils.cs` — Validation/sanitization +- `src/Refitter.Core/PreservingPropertyNameGenerator.cs` — Generator +- `src/Refitter.Core/CamelCasePropertyNameGenerator.cs` — Generator (optional) +- `src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs` — Tests + +**Modified Files (6):** +- `src/Refitter/Settings.cs` — CLI option +- `src/Refitter/GenerateCommand.cs` — Mapper +- `src/Refitter.Core/Settings/RefitGeneratorSettings.cs` — Core property +- `src/Refitter.Core/CSharpClientGeneratorFactory.cs` — Generator routing +- `README.md` — Documentation +- `docs/json-schema.json` — Future compatibility hint + +--- + +## Success Criteria (Phase 1) + +- ✅ CLI option `--property-naming-policy` functional and documented +- ✅ Preserve mode generates snake_case property names with `[JsonPropertyName]` binding +- ✅ All edge cases handled: hyphens (rejected), reserved keywords (`@class`), leading digits (`_123`) +- ✅ 10+ unit tests pass; 0 regressions in existing 1415+ test suite +- ✅ Code compiles, serializes/deserializes correctly +- ✅ `dotnet format` passes + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|-----------| +| Serialization breaks if `[JsonPropertyName]` not emitted | Always emit for all modes; guaranteed by generator code | +| Invalid identifiers cause compilation errors | Sanitization + validation in IdentifierUtils; test coverage | +| Reserved keyword collisions | Auto-escape with `@` prefix; warn on collisions | +| Backward compatibility break | Default = PascalCase; no change to existing behavior | + +--- + +## Next Steps + +1. **Fenster:** Implement Phase 1 per `.squad/implementation-plan-issue-967.md` +2. **Hockney:** Add test coverage (already specified in plan) +3. **McManus:** Monitor CI/CD (no workflow changes needed for Phase 1) +4. **Phase 2 (Future):** Schedule source generator/MSBuild integration once Phase 1 validated + +--- + +## References + +- GitHub Issue: #967 +- Investigation Report: `.squad/agents/fenster/history.md` → Issue #967 Investigation +- Test Design: `.squad/skills/raw-property-names/SKILL.md` +- Implementation Plan: `.squad/implementation-plan-issue-967.md` diff --git a/.squad/implementation-plan-issue-967.md b/.squad/implementation-plan-issue-967.md new file mode 100644 index 00000000..f761819e --- /dev/null +++ b/.squad/implementation-plan-issue-967.md @@ -0,0 +1,669 @@ +# Issue #967 Implementation Plan: Preserve Original Property Names + +**Status:** Ready for Implementation +**Owner:** Fenster (Refitter .NET Dev) +**Related Issue:** https://github.com/christianhelle/refitter/issues/967 +**Priority:** Feature +**Complexity:** Medium (3-4 implementation surfaces, moderate test coverage) + +--- + +## Executive Summary + +**Goal:** Allow generated contract property names to preserve the original OpenAPI property name (e.g., `payMethod_SumBank`) instead of always converting to PascalCase (`PayMethodSumBank`). + +**Product Surface:** CLI option only (Phase 1; .refitter file support deferred) + +**Team Decision:** ✅ APPROVED per `.squad/decisions.md` entry +Team consensus: Use `--property-naming-policy` CLI enum with configurable naming strategies. + +--- + +## 1. Recommended Implementation Shape + +### 1.1 CLI Surface Design +``` +--property-naming-policy [mode] +``` + +**Enum Values (in `PropertyNamingPolicy`):** +- `PascalCase` (default) — Current behavior (mandatory conversion) +- `Preserve` — Use exact OpenAPI property names, sanitized only for C# validity +- `CamelCase` — Convert to camelCase instead of PascalCase +- `SnakeCase` — Convert to snake_case format + +### 1.2 Implementation Strategy + +**Option A (Recommended):** Create pluggable naming generator hierarchy +- Abstract base: `IPropertyNameGenerator` (NSwag interface, already exists) +- Default: `CustomCSharpPropertyNameGenerator` (existing, mandatory PascalCase) +- New: `PreservingPropertyNameGenerator` (sanitizes invalid identifiers only) +- Extensible to support additional policies in future + +**Option B (Alternative):** Enum-based mapper +- Single generator class with policy enum switch +- Simpler but less extensible; may need refactoring if users request custom generators + +**Recommendation:** Option A — uses existing NSwag patterns, easier to extend, aligns with #516 design (custom generators support). + +--- + +## 2. Exact Files/Components Likely to Change + +### 2.1 CLI Layer (New/Modified) + +**`src/Refitter/Settings.cs`** +- Add new property with `[CommandOption("--property-naming-policy")]` +- Type: `PropertyNamingPolicy` (enum) +- Default value: `PropertyNamingPolicy.PascalCase` +- Description: "Naming policy for generated contract properties" + +**`src/Refitter/GenerateCommand.cs`** +- In `CreateRefitGeneratorSettings()` method (line ~300-350) +- Map CLI setting to `RefitGeneratorSettings.PropertyNamingPolicy` +- Instantiate correct generator based on policy enum +- Inject into `CodeGeneratorSettings.PropertyNameGenerator` + +### 2.2 Core Layer (New/Modified) + +**`src/Refitter.Core/PropertyNamingPolicy.cs` (NEW)** +```csharp +namespace Refitter.Core; + +public enum PropertyNamingPolicy +{ + PascalCase = 0, // Default + Preserve = 1, // Use original names + CamelCase = 2, // Convert to camelCase + SnakeCase = 3 // Convert to snake_case +} +``` + +**`src/Refitter.Core/Settings/RefitGeneratorSettings.cs`** +- Add property: `public PropertyNamingPolicy PropertyNamingPolicy { get; set; } = PropertyNamingPolicy.PascalCase;` +- With `[Description]` and `[JsonConverter]` for serialization +- NOT in CodeGeneratorSettings (that's internal NSwag coupling) + +**`src/Refitter.Core/PreservingPropertyNameGenerator.cs` (NEW)** +```csharp +internal class PreservingPropertyNameGenerator : IPropertyNameGenerator +{ + public string Generate(JsonSchemaProperty property) => + string.IsNullOrWhiteSpace(property.Name) + ? "_" + : SanitizeForCSharp(property.Name); + + private static string SanitizeForCSharp(string name) + { + // Remove/replace invalid C# identifier characters + // Detect reserved keywords, add @-prefix if needed + // Handle leading digits with underscore prefix + // Return safe identifier + } +} +``` + +**`src/Refitter.Core/CamelCasePropertyNameGenerator.cs` (NEW)** — Optional for Phase 1 +```csharp +internal class CamelCasePropertyNameGenerator : IPropertyNameGenerator +{ + public string Generate(JsonSchemaProperty property) => + // Convert to camelCase +} +``` + +**`src/Refitter.Core/CSharpClientGeneratorFactory.cs`** +- Line ~33: Change hardcoded generator selection +- From: `PropertyNameGenerator = settings.CodeGeneratorSettings?.PropertyNameGenerator ?? new CustomCSharpPropertyNameGenerator()` +- To: `PropertyNameGenerator = CreatePropertyNameGenerator(settings)` +- New private method: `CreatePropertyNameGenerator(RefitGeneratorSettings)` to route based on `PropertyNamingPolicy` enum + +### 2.3 Shared Infrastructure (New/Modified) + +**`src/Refitter.Core/IdentifierUtils.cs` (NEW OR EXTEND)** +- Consolidate C# identifier validation and sanitization +- Methods: + - `bool IsValidIdentifier(string name)` — Checks if string is valid C# identifier + - `string SanitizeInvalidIdentifier(string name)` — Converts/removes invalid chars + - `string EscapeReservedKeyword(string name)` — Returns `@name` if reserved +- Use in all property name generators to avoid duplication + +**`src/Refitter.Core/Serializer.cs`** (No changes expected) +- `PropertyNamingPolicy` enum will serialize/deserialize via `[JsonConverter]` automatically + +### 2.4 Testing Layer (New) + +**`src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs` (NEW)** +- Test suite covering all 4 enum values +- Tests per SKILL.md validation checklist (5+ test cases minimum) + +--- + +## 3. Ordered Work Breakdown with Dependencies + +### Phase 1: Core Infrastructure (Foundation) + +**Task 1.1: Create PropertyNamingPolicy Enum** +- File: `src/Refitter.Core/PropertyNamingPolicy.cs` +- Deliverable: 4-value enum (PascalCase, Preserve, CamelCase, SnakeCase) +- Dependencies: None +- Effort: 5 minutes +- Validation: Compiles + +**Task 1.2: Create IdentifierUtils sanitization helper** +- File: `src/Refitter.Core/IdentifierUtils.cs` (may extend existing if it exists) +- Deliverable: 3 public methods (IsValidIdentifier, SanitizeInvalidIdentifier, EscapeReservedKeyword) +- Dependencies: None +- Effort: 30 minutes (careful with reserved keyword list) +- Validation: Unit tests (if new file); manual spot-check on examples below: + - `payMethod_SumBank` → valid, no change + - `pay-method` → invalid, convert to `pay_method` or reject + - `class` → reserved keyword, becomes `@class` + - `_123abc` → valid (leading underscore) + - `123abc` → invalid, becomes `_123abc` + +**Task 1.3: Create PreservingPropertyNameGenerator** +- File: `src/Refitter.Core/PreservingPropertyNameGenerator.cs` +- Deliverable: IPropertyNameGenerator implementation; delegates to IdentifierUtils sanitization +- Dependencies: Task 1.2 (IdentifierUtils) +- Effort: 15 minutes +- Validation: Compiles, basic usage test + +**Task 1.4: Create CamelCasePropertyNameGenerator (Optional for MVP)** +- File: `src/Refitter.Core/CamelCasePropertyNameGenerator.cs` +- Deliverable: IPropertyNameGenerator; converts via NSwag's ConversionUtilities +- Dependencies: Task 1.2 (IdentifierUtils) +- Effort: 15 minutes +- Validation: Compiles + +### Phase 2: Settings & Routing (Plumbing) + +**Task 2.1: Add PropertyNamingPolicy to RefitGeneratorSettings** +- File: `src/Refitter.Core/Settings/RefitGeneratorSettings.cs` +- Deliverable: New property + XML doc + `[Description]` + `[JsonConverter]` +- Dependencies: Task 1.1 (PropertyNamingPolicy enum) +- Effort: 10 minutes +- Validation: Serializer.Serialize/Deserialize preserve value; json-schema.json updated + +**Task 2.2: Update CSharpClientGeneratorFactory.Create() routing** +- File: `src/Refitter.Core/CSharpClientGeneratorFactory.cs` +- Deliverable: Replace line 33 logic with new `CreatePropertyNameGenerator()` method +- Dependencies: Tasks 1.1, 1.3, 1.4, 2.1 +- Effort: 15 minutes +- Validation: Factory instantiates correct generator per policy; existing tests still pass + +**Task 2.3: Add CLI option to Settings.cs** +- File: `src/Refitter/Settings.cs` +- Deliverable: New property with `[CommandOption("--property-naming-policy")]` +- Dependencies: Task 1.1 +- Effort: 5 minutes +- Validation: CLI help shows option; defaults to PascalCase + +**Task 2.4: Add mapper in GenerateCommand.cs** +- File: `src/Refitter/GenerateCommand.cs` +- Deliverable: Map CLI Settings.PropertyNamingPolicy to RefitGeneratorSettings.PropertyNamingPolicy +- Dependencies: Tasks 1.1, 2.1, 2.3 +- Effort: 5 minutes +- Validation: CLI option passed through to core; integration test + +### Phase 3: Testing & Validation (Quality) + +**Task 3.1: Create PropertyNamingPolicyTests.cs** +- File: `src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs` +- Deliverable: Test suite with 10+ tests per SKILL.md validation checklist: + - Basic generation with each policy + - Preserve mode: raw snake_case preserved in property name + - PascalCase mode: current behavior maintained (regression test) + - Edge cases: hyphens, spaces, reserved keywords, leading digits, Unicode + - Compilation: generated code builds via BuildHelper + - Serialization roundtrip: JSON deserializes correctly with `[JsonPropertyName]` +- Dependencies: Tasks 1.1–2.4 (entire core implementation) +- Effort: 2 hours +- Validation: All tests pass; 100% code coverage on new generators + +**Task 3.2: Regression testing (existing test suite)** +- Run: `dotnet test -c Release src/Refitter.slnx` +- Deliverable: All 1415+ existing tests pass +- Dependencies: All previous tasks +- Effort: 15 minutes (wait time only) +- Validation: Zero new test failures + +### Phase 4: Documentation & Polish + +**Task 4.1: Update README.md** +- File: `README.md` section on CLI OPTIONS +- Deliverable: + - Add `--property-naming-policy` to option list with description + - Add example usage: `refitter ./openapi.json --property-naming-policy Preserve` + - Add explanation of each enum value and use cases + - Note: `[JsonPropertyName]` attribute is automatically generated for binding +- Effort: 30 minutes +- Validation: README.md is valid markdown; .refitter example JSON remains valid + +**Task 4.2: Update json-schema.json** +- File: `docs/json-schema.json` +- Deliverable: + - Add `propertyNamingPolicy` property definition with enum constraint + - Add example value in .refitter sample +- Effort: 15 minutes +- Validation: `ConvertFrom-Json` succeeds; schema remains valid JSON Schema + +**Task 4.3: Code formatting & final validation** +- Run: `dotnet format src/Refitter.slnx` +- Run: `dotnet format --verify-no-changes src/Refitter.slnx` +- Run: Full build & test cycle +- Effort: 5 minutes (automated) +- Validation: Zero formatting violations; build succeeds + +--- + +## 4. Test Strategy and Validation Commands + +### 4.1 Unit Tests (Automated) + +**Test 1: Basic Preserve Mode** +```csharp +[Test] +public async Task Preserve_Mode_Generates_Snake_Case_Property_Names() +{ + var spec = @" +openapi: 3.0.0 +info: + title: API + version: 1.0.0 +components: + schemas: + Item: + type: object + properties: + payMethod_SumBank: + type: number + order_ID: + type: string +"; + var settings = new RefitGeneratorSettings { PropertyNamingPolicy = PropertyNamingPolicy.Preserve }; + var code = await GenerateCode(spec, settings); + + code.Should().Contain("public double? payMethod_SumBank"); + code.Should().Contain("public string? order_ID"); + code.Should().NotContain("PayMethodSumBank"); + code.Should().NotContain("OrderID"); +} +``` + +**Test 2: PascalCase Mode (Regression)** +```csharp +[Test] +public async Task PascalCase_Mode_Maintains_Current_Behavior() +{ + var spec = /* same as above */; + var settings = new RefitGeneratorSettings { PropertyNamingPolicy = PropertyNamingPolicy.PascalCase }; + var code = await GenerateCode(spec, settings); + + code.Should().NotContain("payMethod_SumBank"); + code.Should().Contain("public double? PayMethodSumBank"); +} +``` + +**Test 3: Invalid C# Identifier Handling** +```csharp +[Test] +public async Task Preserve_Mode_Rejects_Invalid_Identifiers() +{ + var spec = @" +components: + schemas: + Item: + properties: + pay-method: // Hyphens invalid in C# + type: string +"; + var settings = new RefitGeneratorSettings { PropertyNamingPolicy = PropertyNamingPolicy.Preserve }; + + var action = async () => await GenerateCode(spec, settings); + await action.Should().ThrowAsync() + .WithMessage("*invalid C# identifier*"); +} +``` + +**Test 4: Reserved Keyword Escaping** +```csharp +[Test] +public async Task Preserve_Mode_Escapes_Reserved_Keywords() +{ + var spec = @" +components: + schemas: + Item: + properties: + class: + type: string + return: + type: string +"; + var settings = new RefitGeneratorSettings { PropertyNamingPolicy = PropertyNamingPolicy.Preserve }; + var code = await GenerateCode(spec, settings); + + code.Should().Contain("public string? @class"); + code.Should().Contain("public string? @return"); +} +``` + +**Test 5: Compilation & Serialization** +```csharp +[Test] +public async Task Generated_Code_With_Preserve_Mode_Compiles_And_Deserializes() +{ + var spec = /* snake_case properties */; + var code = await GenerateCode(spec, new RefitGeneratorSettings { PropertyNamingPolicy = PropertyNamingPolicy.Preserve }); + var dll = BuildHelper.BuildCSharp(code); + + var json = @"{""payMethod_SumBank"": 123.45}"; + var obj = JsonSerializer.Deserialize(json, dll.GetType("Item")); + + obj.GetProperty("payMethod_SumBank").GetValue(obj).Should().Be(123.45d); +} +``` + +### 4.2 Integration Tests (Manual/CLI) + +**Validation 1: CLI Help** +```bash +dotnet run --project src/Refitter --framework net9.0 -- --help | grep -A5 property-naming-policy +# Expected output: --property-naming-policy option listed with description and enum values +``` + +**Validation 2: CLI Generation with Sample Spec** +```bash +# Create temp spec with snake_case properties +cat > /tmp/test-api.json << 'EOF' +{ + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "order_id": {"type": "integer"}, + "payment_method": {"type": "string"} + } + } + } + } +} +EOF + +# Generate with Preserve mode +dotnet run --project src/Refitter --framework net9.0 -- \ + /tmp/test-api.json \ + --property-naming-policy Preserve \ + --output /tmp/test-preserved.cs + +# Verify output +grep -q "public.*order_id" /tmp/test-preserved.cs && echo "✓ Preserve mode works" + +# Generate with PascalCase (default) +dotnet run --project src/Refitter --framework net9.0 -- \ + /tmp/test-api.json \ + --output /tmp/test-pascal.cs + +# Verify output +grep -q "public.*OrderId" /tmp/test-pascal.cs && echo "✓ PascalCase mode works" +``` + +**Validation 3: Serialization Roundtrip** +```csharp +// Manual test: Create small console app with generated contract +var json = @"{""order_id"": 42, ""payment_method"": ""credit_card""}"; +var order = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); + +Assert.NotNull(order); +Assert.Equal(42, order.order_id); +Assert.Equal("credit_card", order.payment_method); + +// Serialize back +var json2 = JsonSerializer.Serialize(order); +Assert.Contains("\"order_id\"", json2); +``` + +### 4.3 Build & Format Validation + +```bash +# Full validation pipeline (required before PR) +dotnet build -c Release src/Refitter.slnx +dotnet test -c Release src/Refitter.slnx # Allow network-related failures only +dotnet format --verify-no-changes src/Refitter.slnx +``` + +--- + +## 5. Risks, Edge Cases, and Considerations + +### 5.1 Critical Edge Cases + +| Edge Case | Risk | Mitigation | +|-----------|------|-----------| +| **Hyphenated names** (`pay-method`) | Invalid C# identifier | Reject in Preserve mode with clear error; offer alternative (sanitize to `pay_method`) | +| **Reserved keywords** (`class`, `return`) | Compilation error | Auto-escape with `@` prefix (e.g., `@class`) | +| **Leading digit** (`123abc`) | Invalid C#; SyntaxError | Prepend underscore → `_123abc` | +| **Spaces/special chars** | Invalid C# | Either reject or map to safe replacement | +| **Unicode** (`café_name`) | Valid in C# identifiers | No action; UTF-16 native support | +| **Collision** (`Class` → `@class` and `_class` → `_class`) | Two properties same final name | Emit warning; use sequential suffix (`_class`, `_class2`) | +| **[JsonPropertyName] missing** | Deserialization fails | ALWAYS emit `[JsonPropertyName("originalName")]` regardless of mode | + +### 5.2 Product Considerations + +- **Backward Compatibility:** Default is `PascalCase`; no breaking change +- **Serialization:** `.refitter` file changes: `propertyNamingPolicy` added to schema; old files without it default to PascalCase +- **Source Generator:** Must support `propertyNamingPolicy` in `.refitter` files (future phase) +- **Documentation:** Clarify that `[JsonPropertyName]` is always generated for binding to work correctly + +### 5.3 Implementation Complexity Notes + +- **NSwag Integration:** `IPropertyNameGenerator` is external interface; cannot extend via JSON schema discriminator. CLI-only is correct choice. +- **Keyword Detection:** Use `Microsoft.CodeAnalysis.CSharp.SyntaxFacts.IsReservedKeyword()` if available; otherwise maintain C# v11 keyword list +- **Character Validation:** `char.IsLetterOrDigit()` + `_` is base validation; full `[A-Za-z_][A-Za-z0-9_]*` pattern is more reliable + +### 5.4 Documentation Gaps to Address + +- Add section to README: "Property Naming Policies" +- Note: Preserve mode requires `[JsonPropertyName]` for deserialization (auto-generated) +- Clarify when to use each policy: + - **PascalCase** (default): Standard .NET convention, breaking with API naming + - **Preserve**: Match API exactly, maintain consistency with backend + - **CamelCase**: JavaScript-compatible naming in contracts + - **SnakeCase**: Rare, but for APIs strictly using snake_case + +--- + +## 6. Source-Generator & Shared-Source Implications + +### 6.1 Refitter.SourceGenerator Impact + +**Status:** DEFERRED (Phase 2) + +The source generator reads `.refitter` settings files and should support `propertyNamingPolicy`. However: + +1. Current limitation: `.refitter` files cannot deserialize interface types (e.g., `IPropertyNameGenerator`) +2. Workaround: Use enum + factory pattern (same as CLI) +3. Implementation: After CLI Phase 1 completes, add `propertyNamingPolicy` property to `.refitter` schema and update source generator to instantiate correct generator based on enum + +**Required Changes (Phase 2):** +- `Refitter.SourceGenerator/RefitterSourceGenerator.cs`: Add routing logic (copy from GenerateCommand) +- Update `.refitter` file schema documentation +- No changes needed to generator classes themselves + +### 6.2 Refitter.MSBuild Impact + +**Status:** SAME AS SOURCE GENERATOR (Deferred Phase 2) + +The MSBuild task also reads `.refitter` files. Same implementation pattern applies. + +### 6.3 Shared-Source Considerations + +- **IdentifierUtils:** Used by both CLI and source generator; place in `Refitter.Core` (shared) +- **PropertyNamingPolicy enum:** Used by both; place in `Refitter.Core` +- **Generator classes:** (PreservingPropertyNameGenerator, CamelCasePropertyNameGenerator) used by both; place in `Refitter.Core` +- **GenerateCommand routing logic:** CLI-specific; keep in `src/Refitter/`; source generator will have its own copy + +### 6.4 Schema Evolution + +**`docs/json-schema.json` changes (Phase 1 AND Phase 2):** + +Add to `.refitter` schema: +```json +"propertyNamingPolicy": { + "type": "string", + "description": "Naming policy for generated contract properties", + "enum": ["PascalCase", "Preserve", "CamelCase", "SnakeCase"], + "default": "PascalCase" +} +``` + +--- + +## 7. Success Criteria & Acceptance Tests + +### Phase 1 (CLI) — MUST PASS + +- [ ] `PropertyNamingPolicy` enum created and compiles +- [ ] `PreservingPropertyNameGenerator` created; generates snake_case names +- [ ] CLI option `--property-naming-policy` added and functional +- [ ] All 10+ unit tests pass (PropertyNamingPolicyTests.cs) +- [ ] All 1415+ existing tests still pass (zero regressions) +- [ ] `dotnet format --verify-no-changes` passes +- [ ] Manual CLI validation: `--property-naming-policy Preserve` generates correct code +- [ ] Serialization roundtrip test passes +- [ ] README.md updated with new option and examples +- [ ] `docs/json-schema.json` updated (if needed for future compatibility) + +### Phase 2 (Source Generator/MSBuild) — FUTURE + +- [ ] `.refitter` files support `propertyNamingPolicy` property +- [ ] Source generator instantiates correct generator based on setting +- [ ] MSBuild task respects setting +- [ ] Documentation updated for `.refitter` files + +--- + +## 8. Timeline & Effort Estimate + +| Phase | Task Count | Estimated Effort | Critical Path | +|-------|-----------|------------------|----------------| +| 1 Core Infrastructure | 4 tasks | 1 hour | IdentifierUtils → Generators → Enum | +| 2 Settings & Routing | 4 tasks | 45 min | PropertyNamingPolicy enum + factory | +| 3 Testing | 2 tasks | 2.5 hours | Depends on all of Phase 1–2 | +| 4 Documentation | 3 tasks | 1 hour | Can run in parallel | +| **TOTAL** | **13 tasks** | **5 hours** | **Day 1** | + +**Phase 2 (Source Generator/MSBuild, Future):** +2 hours, after Phase 1 validated + +--- + +## 9. File Checklist (Complete List of Changes) + +### New Files +- [ ] `src/Refitter.Core/PropertyNamingPolicy.cs` +- [ ] `src/Refitter.Core/IdentifierUtils.cs` (or extend if exists) +- [ ] `src/Refitter.Core/PreservingPropertyNameGenerator.cs` +- [ ] `src/Refitter.Core/CamelCasePropertyNameGenerator.cs` (optional MVP) +- [ ] `src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs` + +### Modified Files +- [ ] `src/Refitter/Settings.cs` — Add `PropertyNamingPolicy` CLI option +- [ ] `src/Refitter/GenerateCommand.cs` — Add mapper in `CreateRefitGeneratorSettings()` +- [ ] `src/Refitter.Core/Settings/RefitGeneratorSettings.cs` — Add `PropertyNamingPolicy` property +- [ ] `src/Refitter.Core/CSharpClientGeneratorFactory.cs` — Update line 33 generator selection logic +- [ ] `README.md` — Add CLI option documentation and examples +- [ ] `docs/json-schema.json` — Add `propertyNamingPolicy` property definition + +### No Changes Required +- `src/Refitter.Core/CustomCSharpPropertyNameGenerator.cs` — Keep as-is (for PascalCase mode) +- `src/Refitter.Core/Settings/CodeGeneratorSettings.cs` — Keep `PropertyNameGenerator` as-is (internal) +- `src/Refitter.Tests/SerializerTests.cs` — Existing exclusions remain valid + +--- + +## 10. Known Unknowns & Decision Points + +1. **Hyphen Handling:** Should Preserve mode reject hyphens outright, or sanitize to underscore? + - **Decision:** Reject with clear error message (safer, prevents silent behavior changes) + +2. **Keyword Escaping in Preserve Mode:** Should we use `@` prefix or `_` suffix for reserved keywords? + - **Decision:** Use `@` prefix (standard C# pattern for keyword avoidance) + +3. **CamelCase Mode Priority:** Should we implement this in Phase 1, or defer? + - **Decision:** Defer to Phase 1b if time permits; Preserve mode is MVP + +4. **Name Collision Resolution:** If `Class` and `_class` both exist, how to handle? + - **Decision:** Emit warning; use sequential suffix (`_class2`, `_class3`) + +5. **Preserve Mode Default:** Should new projects default to Preserve or PascalCase? + - **Decision:** Default to PascalCase (backward compatible); users opt-in to Preserve + +--- + +## Appendix: Example Generated Output + +### Input OpenAPI Schema +```yaml +components: + schemas: + PaymentOrder: + type: object + properties: + payMethod_SumBank: + type: number + order_id: + type: integer + customer_Name: + type: string +``` + +### Output with PascalCase (Current, Default) +```csharp +public class PaymentOrder +{ + [JsonPropertyName("payMethod_SumBank")] + public double? PayMethodSumBank { get; set; } + + [JsonPropertyName("order_id")] + public int? OrderId { get; set; } + + [JsonPropertyName("customer_Name")] + public string? CustomerName { get; set; } +} +``` + +### Output with Preserve Mode (NEW) +```csharp +public class PaymentOrder +{ + [JsonPropertyName("payMethod_SumBank")] + public double? payMethod_SumBank { get; set; } + + [JsonPropertyName("order_id")] + public int? order_id { get; set; } + + [JsonPropertyName("customer_Name")] + public string? customer_Name { get; set; } +} +``` + +**Key Difference:** Property names match OpenAPI exactly; `[JsonPropertyName]` attribute ensures correct serialization. + +--- + +## Document Metadata + +**Version:** 1.0 +**Created:** 2026-03-25 +**Author:** Fenster (Refitter .NET Dev) +**Team Consensus Date:** 2026-03-25 (per `.squad/decisions.md`) +**Next Review:** After Phase 1 implementation complete +**Related Artifacts:** +- `.squad/agents/fenster/history.md` — Issue #967 investigation findings +- `.squad/skills/raw-property-names/SKILL.md` — Test design & edge-case validation +- `.squad/decisions.md` — Team consensus on feature approval +- GitHub Issue #967 — Feature request and discussion diff --git a/.squad/orchestration-log/2026-03-25T08-07-15Z-fenster.md b/.squad/orchestration-log/2026-03-25T08-07-15Z-fenster.md new file mode 100644 index 00000000..bba1535e --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T08-07-15Z-fenster.md @@ -0,0 +1,92 @@ +# Orchestration Log: Fenster + +**Timestamp:** 2026-03-25T08:07:15Z +**Agent:** Fenster (.NET Dev) +**Mode:** background +**Reason:** Implementation-path investigation for GitHub issue #967 + +--- + +## Task + +Identify exact implementation surfaces and product surfaces needed to expose property name generator support to end users. + +--- + +## Artifacts Analyzed + +- `src\Refitter.Core\CustomCSharpPropertyNameGenerator.cs` +- `src\Refitter.Core\CSharpClientGeneratorFactory.cs` +- `src\Refitter.Core\Serializer.cs` +- `src\Refitter.Core\Settings\CodeGeneratorSettings.cs` +- `src\Refitter.Core\Settings\RefitGeneratorSettings.cs` +- `src\Refitter.Core\Settings\NamingSettings.cs` +- `src\Refitter.Tests\SerializerTests.cs` +- `src\Refitter.Tests\Examples\NamingSettingsTests.cs` +- `docs\json-schema.json` +- `CHANGELOG.md` + +--- + +## Key Findings + +### 1. Hardcoded Property Generation +- **Location:** `CustomCSharpPropertyNameGenerator.cs:45-47` +- **Code:** `ConversionUtilities.ConvertToUpperCamelCase(name, true).ConvertSnakeCaseToPascalCase()` +- **Result:** Unconditional PascalCase transformation; no user control + +### 2. End-User Inaccessibility +- **CLI:** No `--property-naming-policy` option exists +- **.refitter file:** Property has `[JsonIgnore]`, blocks JSON deserialization +- **Source Generator:** No user-facing mechanism + +### 3. Injection Point Identified +- **Location:** `CSharpClientGeneratorFactory.cs:33` +- **Code:** `PropertyNameGenerator = settings.CodeGeneratorSettings?.PropertyNameGenerator ?? new CustomCSharpPropertyNameGenerator()` +- **Implication:** Respects custom generator if provided; only defaults to hardcoded one + +### 4. Required Implementation Surfaces + +#### CLI Option +```csharp +[CommandOption("--property-naming-policy")] +[Description("Property naming convention: PascalCase (default) or PreserveOriginal")] +public string PropertyNamingPolicy { get; set; } = "PascalCase"; +``` + +#### Settings Mapping +Map CLI option → `RefitGeneratorSettings.PropertyNamingPolicy` → factory selection + +#### New Generator Class +```csharp +internal class PreserveOriginalPropertyNameGenerator : IPropertyNameGenerator +{ + public string Generate(JsonSchemaProperty property) => + SanitizeInvalidIdentifiers(property.Name); +} +``` + +### 5. Edge Cases Identified +- **Reserved keywords:** Need `@` prefix or suffix handling +- **Invalid identifiers:** Spaces, hyphens, leading digits require sanitization +- **JsonPropertyName attribute:** MUST still be generated for correct deserialization +- **Name collisions:** If sanitization produces duplicates, generation fails + +--- + +## Recommendation + +**Implement CLI-only initially.** `.refitter` file support requires complex polymorphic deserialization strategy (worth future work but defer). + +**Effort estimate:** 4-6 hours (implementation + testing + documentation) + +--- + +## Files Produced + +- `.squad/decisions/inbox/fenster-issue-967-investigation.md` +- `.squad/agents/fenster/history.md` + +--- + +**Status:** ✅ COMPLETE diff --git a/.squad/orchestration-log/2026-03-25T08-07-15Z-hockney.md b/.squad/orchestration-log/2026-03-25T08-07-15Z-hockney.md new file mode 100644 index 00000000..8ada02b2 --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T08-07-15Z-hockney.md @@ -0,0 +1,116 @@ +# Orchestration Log: Hockney + +**Timestamp:** 2026-03-25T08:07:15Z +**Agent:** Hockney (Tester) +**Mode:** background +**Reason:** Compile-safety and validation assessment for GitHub issue #967 + +--- + +## Task + +Assess safety, compile-time behavior, and testing requirements for raw property names (`payMethod_SumBank` instead of `PayMethodSumBank`). + +--- + +## Artifacts Analyzed + +- `src\Refitter.Core\CustomCSharpPropertyNameGenerator.cs` +- `src\Refitter.Core\CSharpClientGeneratorFactory.cs` +- `src\Refitter.Core\Serializer.cs` +- `src\Refitter.Core\Settings\CodeGeneratorSettings.cs` +- `src\Refitter.Core\Settings\RefitGeneratorSettings.cs` +- `src\Refitter.Core\Settings\NamingSettings.cs` +- `src\Refitter.Tests\SerializerTests.cs` +- `src\Refitter.Tests\Examples\NamingSettingsTests.cs` +- `docs\json-schema.json` +- `CHANGELOG.md` + +--- + +## Key Findings + +### 1. Compile Safety: ✅ SAFE +- **C# identifier validity:** `payMethod_SumBank` compiles without errors +- **System.Text.Json:** Correctly maps JSON name via `[JsonPropertyName]` attribute +- **Serialization/deserialization:** Works correctly both directions +- **No compiler warnings:** Tested with net10.0 target + +### 2. Edge Cases & Mitigation Required + +| Case | Risk | Mitigation | +|------|------|-----------| +| **Hyphens (kebab-case)** | ❌ Invalid C# identifier | Validation + sanitization needed | +| **Spaces/special chars** | ❌ Invalid C# identifier | Sanitization required | +| **Reserved keywords** (e.g., `class`) | ⚠️ Compilation fails | Use verbatim prefix `@` | +| **Name collisions** | ⚠️ Generated code fails | Validate no duplicates after sanitization | +| **Leading digits** | ❌ Invalid C# identifier | Prepend underscore | + +### 3. Current Codebase State + +**Blockers identified:** +1. **User Access:** No CLI option or `.refitter` config to use custom generator +2. **JSON Schema:** Schema claims support but `[JsonIgnore]` prevents it +3. **Serialization Tests:** Explicitly exclude `PropertyNameGenerator` from round-trip tests + +**Infrastructure exists:** +- `CodeGeneratorSettings.PropertyNameGenerator` property (line 264-266) +- `CSharpClientGeneratorFactory` respects custom generators (line 33) +- NSwag/NJsonSchema support `IPropertyNameGenerator` + +### 4. Required Tests (If Feature Approved) + +```csharp +[Test] +public async Task Can_Generate_Code_With_Raw_Property_Names() +{ + var code = await GenerateWithPropertyNameGenerator( + customNameGenerator: new IdentityPropertyNameGenerator() + ); + code.Should().NotBeNullOrWhiteSpace(); +} + +[Test] +public async Task Generated_Code_Uses_Raw_Property_Names() +{ + var code = await GenerateWithPropertyNameGenerator( + customNameGenerator: new IdentityPropertyNameGenerator() + ); + code.Should().Contain("payMethod_SumBank { get; set; }"); + code.Should().NotContain("PayMethodSumBank"); +} + +[Test] +public async Task Can_Build_Generated_Code_With_Raw_Names() +{ + var code = await GenerateWithPropertyNameGenerator( + customNameGenerator: new IdentityPropertyNameGenerator() + ); + BuildHelper.BuildCSharp(code).Should().BeTrue(); +} + +[Test] +public async Task Invalid_Property_Names_Are_Rejected() +{ + // e.g., JSON name "my-property" (hyphens not valid in C# identifiers) + // Should raise validation error or sanitize name +} +``` + +--- + +## Safety Verdict + +**✅ SAFE to implement.** Raw property names are valid C#, compile correctly, and serialize/deserialize reliably. Required precaution: comprehensive edge-case testing (reserved keywords, hyphens, leading digits, collisions). + +--- + +## Files Produced + +- `.squad/decisions/inbox/hockney-issue-967.md` +- `.squad/agents/hockney/history.md` +- `.squad/skills/raw-property-names/SKILL.md` + +--- + +**Status:** ✅ COMPLETE diff --git a/.squad/orchestration-log/2026-03-25T08-07-15Z-keaton.md b/.squad/orchestration-log/2026-03-25T08-07-15Z-keaton.md new file mode 100644 index 00000000..9ccc9b52 --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T08-07-15Z-keaton.md @@ -0,0 +1,71 @@ +# Orchestration Log: Keaton + +**Timestamp:** 2026-03-25T08:07:15Z +**Agent:** Keaton (Lead / Architect) +**Mode:** background +**Reason:** Team feasibility and product-shape assessment for GitHub issue #967 + +--- + +## Task + +Evaluate feasibility and recommend product shape for GitHub issue #967 (Preserve Original Property Names in generated C# interfaces). + +--- + +## Artifacts Analyzed + +- `src\Refitter.Core\CustomCSharpPropertyNameGenerator.cs` +- `src\Refitter.Core\CSharpClientGeneratorFactory.cs` +- `src\Refitter.Core\Serializer.cs` +- `src\Refitter.Core\Settings\CodeGeneratorSettings.cs` +- `src\Refitter.Core\Settings\RefitGeneratorSettings.cs` +- `src\Refitter.Core\Settings\NamingSettings.cs` +- `src\Refitter.Tests\SerializerTests.cs` +- `src\Refitter.Tests\Examples\NamingSettingsTests.cs` +- `docs\json-schema.json` +- `CHANGELOG.md` + +--- + +## Key Findings + +### 1. Feasibility: YES +- Architecture supports pluggable `IPropertyNameGenerator` at factory level +- NSwag infrastructure is in place +- No breaking changes required + +### 2. Current Blockers +- Property generation is hardcoded to `ConvertToUpperCamelCase().ConvertSnakeCaseToPascalCase()` with no user exposure +- `PropertyNameGenerator` property is `[JsonIgnore]` — blocks `.refitter` JSON deserialization +- No CLI option exists to configure custom generators +- JSON schema exposes a dead/non-functional API surface + +### 3. Recommended Product Shape +- **New enum:** `PropertyNamingPolicy` on `RefitGeneratorSettings` with values: `PascalCase` (default), `PreserveOriginal` +- **New generator:** `PreserveOriginalPropertyNameGenerator` that only sanitizes C#-illegal characters +- **CLI option:** `--property-naming-policy` +- **Effort:** Small (1-2 days) + +### 4. Schema Issues Found +- `propertyNameGenerator` in schema marked `[JsonIgnore]` in code +- `IPropertyNameGenerator` defined as bare object with no instantiation strategy +- `inlineJsonConverters` not in schema +- `customTemplateDirectory` duplicated + +--- + +## Recommendation + +**Proceed with implementation.** This is feasible and worth doing. Product shape is clean, effort is modest, and the feature addresses a real use case. + +--- + +## Files Produced + +- `.squad/decisions/inbox/keaton-issue-967.md` +- `.squad/agents/keaton/history.md` + +--- + +**Status:** ✅ COMPLETE diff --git a/.squad/orchestration-log/2026-03-25T09-16-16Z-fenster.md b/.squad/orchestration-log/2026-03-25T09-16-16Z-fenster.md new file mode 100644 index 00000000..75318aba --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T09-16-16Z-fenster.md @@ -0,0 +1,48 @@ +# Orchestration: Fenster — Issue #967 Implementation + +**Timestamp:** 2026-03-25T09:16:16Z +**Agent:** Fenster (.NET Dev) +**Mode:** background +**Status:** COMPLETED + +## Spawn Reason +Implemented issue #967 property naming feature across product surfaces: CLI, `.refitter` file format, Source Generator, documentation, and schema. + +## Work Products + +### Code Modules Created/Modified +- `src/Refitter.Core/Settings/PropertyNamingPolicy.cs` — New enum `PropertyNamingPolicy { PascalCase, PreserveOriginal }` +- `src/Refitter.Core/PreserveOriginalPropertyNameGenerator.cs` — New generator for preserving original OpenAPI names +- `src/Refitter.Core/CSharpClientGeneratorFactory.cs` — Integrated `PropertyNamingPolicy` selection +- `src/Refitter.Core/IdentifierUtils.cs` — New shared helper for C# identifier validation/sanitization +- `src/Refitter.Core/Settings/RefitGeneratorSettings.cs` — Added `PropertyNamingPolicy` property +- `src/Refitter/Settings.cs` — Added `--property-naming-policy` CLI option +- `src/Refitter/GenerateCommand.cs` — Mapped CLI setting to core `RefitGeneratorSettings` + +### Documentation +- `docs/json-schema.json` — Updated schema; removed misleading `propertyNameGenerator` entry +- `docs/docfx_project/articles/refitter-file-format.md` — Added `propertyNamingPolicy` enum documentation +- `README.md` — Added `--property-naming-policy` option to CLI reference + +### History +- `.squad/agents/fenster/history.md` — Session log + +## Validation Gates +- ✅ CLI `--help` displays `--property-naming-policy` with correct enum values +- ✅ Direct CLI generation preserves raw property names and emits `[JsonPropertyName]` +- ✅ `.refitter` file generation preserves raw property names and emits `[JsonPropertyName]` +- ✅ `dotnet build -c Release src/Refitter.slnx` — SUCCESS +- ✅ `dotnet test -c Release --solution src/Refitter.slnx` — ALL PASS +- ✅ `dotnet format --verify-no-changes src/Refitter.slnx` — CLEAN + +## Key Decisions +1. **Simple enum over polymorphic JSON:** `PropertyNamingPolicy` is a serializable enum (not a factory/interface), enabling instant parity across CLI, `.refitter`, Source Generator, and MSBuild without polymorphic JSON complexity. +2. **Preserve original strategy:** Invalid C# identifiers are minimally sanitized (underscores for invalid chars, prefix for invalid starts). Reserved keywords are escaped with `@`. Valid identifiers pass through unchanged. +3. **`[JsonPropertyName]` always emitted:** Ensures serialization correctness regardless of property naming policy. + +## Known Follow-ups +- **Non-blocking:** IdentifierUtils contains three public methods (`ToCompilableIdentifier`, `IsValidIdentifier`, `EscapeReservedKeyword`) with zero external callers. `PreserveOriginalPropertyNameGenerator` reimplements equivalent logic. Consolidate or remove in a follow-up PR. + +## Integration Points +- Consumed by Hockney (tester) for regression coverage +- Reviewed by Keaton (lead) for approval diff --git a/.squad/orchestration-log/2026-03-25T09-16-16Z-hockney.md b/.squad/orchestration-log/2026-03-25T09-16-16Z-hockney.md new file mode 100644 index 00000000..6c595b02 --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T09-16-16Z-hockney.md @@ -0,0 +1,46 @@ +# Orchestration: Hockney — Issue #967 Regression Coverage + +**Timestamp:** 2026-03-25T09:16:16Z +**Agent:** Hockney (Tester) +**Mode:** background +**Status:** COMPLETED + +## Spawn Reason +Added comprehensive regression coverage for issue #967 property naming feature including CLI binding, Source Generator parity, and edge-case validation. + +## Work Products + +### Test Modules Created/Modified +- `src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs` — Core unit tests for PascalCase default, PreserveOriginal output, reserved keywords, invalid identifier sanitization, compilation checks +- `src/Refitter.Tests/GenerateCommandTests.cs` — CLI argument parsing for `--property-naming-policy` +- `src/Refitter.Tests/SerializerTests.cs` — Settings deserialization round-trip validation +- `src/Refitter.Tests/SettingsTests.cs` — Settings defaults and binding coverage + +### Source Generator Tests +- `src/Refitter.SourceGenerator.Tests/Refitter.SourceGenerator.Tests.csproj` — Project configuration updates +- `src/Refitter.SourceGenerator.Tests/PropertyNamingPolicyTests.cs` — Source Generator end-to-end tests via `.refitter` file +- `src/Refitter.SourceGenerator.Tests/AdditionalFiles/PropertyNamingPolicy.refitter` — Test fixture with property naming configuration +- `src/Refitter.SourceGenerator.Tests/Resources/PropertyNamingPolicy.json` — OpenAPI spec with varied property names for testing + +### Documentation / Skills +- `.squad/agents/hockney/history.md` — Session log +- `.squad/skills/raw-property-names/SKILL.md` — Shared skill for property naming edge cases + +## Test Coverage +1. **Default PascalCase regression:** Existing contracts generated with PascalCase unchanged +2. **PreserveOriginal identifiers:** Valid C# identifiers preserved exactly (e.g., `payMethod_SumBank` → `payMethod_SumBank`) +3. **Reserved keyword escaping:** C# keywords prefixed with `@` (e.g., `class` → `@class`) +4. **Invalid identifier sanitization:** Invalid chars replaced with `_`, invalid starts prefixed with `_` (e.g., `1st-payment-method` → `_1st_payment_method`) +5. **Compilation validation:** Generated DTOs with preserved names compile successfully +6. **Serializer round-trip:** Settings serialize and deserialize correctly via `PropertyNamingPolicy` enum +7. **CLI binding:** `--property-naming-policy PreserveOriginal` correctly sets the enum value +8. **Source Generator parity:** `.refitter` file with `propertyNamingPolicy` generates identical output to CLI + +## Validation Gates +- ✅ `dotnet build -c Release src/Refitter.slnx` — SUCCESS +- ✅ `dotnet test -c Release --solution src/Refitter.slnx` — ALL 1468/1468 PASS +- ✅ `dotnet format --verify-no-changes src/Refitter.slnx` — CLEAN + +## Integration Points +- Validated Fenster's implementation (code generation) +- Provided input to Keaton's approval decision diff --git a/.squad/orchestration-log/2026-03-25T09-16-16Z-keaton.md b/.squad/orchestration-log/2026-03-25T09-16-16Z-keaton.md new file mode 100644 index 00000000..7fc1ea1d --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T09-16-16Z-keaton.md @@ -0,0 +1,58 @@ +# Orchestration: Keaton — Issue #967 Review & Approval + +**Timestamp:** 2026-03-25T09:16:16Z +**Agent:** Keaton (Lead / Architect) +**Mode:** background +**Status:** COMPLETED + +## Spawn Reason +Reviewed current issue #967 diff, validated implementation correctness, and approved for merge pending PR creation. + +## Work Products + +### Review Artifacts +- `.squad/agents/keaton/history.md` — Session log with detailed review notes +- `.squad/decisions/inbox/keaton-issue-967-review.md` — Approval decision and findings + +## Review Summary + +### Approval Decision +✅ **APPROVED FOR MERGE** — All three gates pass: +- Build: 0 errors, 0 warnings +- Tests: 1468/1468 pass +- Format: clean + +### Validation Findings + +**Feature Correctness (VERIFIED):** +- `PreserveOriginalPropertyNameGenerator` correctly handles valid identifiers (pass-through) +- Reserved C# keywords properly escaped with `@` prefix +- Invalid start characters prefixed with `_` (e.g., `1st` → `_1st`) +- Invalid body characters replaced with `_` (e.g., `-` → `_`) +- Programmatic `IPropertyNameGenerator` override takes precedence as designed + +**Test Coverage (COMPREHENSIVE):** +- Core unit tests: PascalCase regression, preserved identifiers, keyword escaping, invalid identifier sanitization, compilation checks +- Serializer round-trip: Settings deserialize from JSON correctly +- CLI mapping: `--property-naming-policy` argument binds correctly +- Settings defaults: `PropertyNamingPolicy.PascalCase` is default +- Source Generator end-to-end: `.refitter` file generation produces identical output to CLI + +**Previous Test Failures (ROOT CAUSE IDENTIFIED):** +- Issue was NOT a code defect +- Root cause: `dotnet test` CLI syntax — positional solution argument now requires `--solution` flag +- Tests pass when invoked correctly: `dotnet test -c Release --solution src/Refitter.slnx` + +## Follow-up Actions (Non-Blocking) + +**Dead Code in IdentifierUtils:** +- Three new public methods have zero external callers: + - `ToCompilableIdentifier` + - `IsValidIdentifier` + - `EscapeReservedKeyword` +- `PreserveOriginalPropertyNameGenerator` reimplements equivalent logic with broader keyword list +- **Action:** Consolidate or remove in a follow-up PR. Assign to Fenster. + +## Integration Points +- Reviewed Fenster's implementation and Hockney's test coverage +- Approved and cleared for PR creation diff --git a/.squad/orchestration-log/2026-03-25T10-34-40Z-scribe.md b/.squad/orchestration-log/2026-03-25T10-34-40Z-scribe.md new file mode 100644 index 00000000..3939d36e --- /dev/null +++ b/.squad/orchestration-log/2026-03-25T10-34-40Z-scribe.md @@ -0,0 +1,25 @@ +# Orchestration Log: Issue #967 Final .squad/ Commit + +**Timestamp:** 2026-03-25T10:34:40Z +**Agent:** Scribe +**Phase:** Finalization + +## Mission +Merge pending decisions inbox and commit remaining `.squad/` infrastructure changes after McManus and Coordinator completed non-.squad commits for issue #967. + +## Execution +1. Processed `.squad/decisions/inbox/copilot-directive-20260325T092340Z.md` + - Decision: Adopt small logical group commit practice for all agent work + - Merged into `.squad/decisions.md` with deduplication check + +2. Cleaned inbox directory post-merge + +3. Logged session activities to `.squad/log/` + +4. Created final commit with Git trailer for attribution + +## Result +✓ All squad state committed and clean +✓ Inbox cleared +✓ Decision state finalized +✓ Ready for next agent phase diff --git a/.squad/skills/raw-property-names/SKILL.md b/.squad/skills/raw-property-names/SKILL.md new file mode 100644 index 00000000..b332dbda --- /dev/null +++ b/.squad/skills/raw-property-names/SKILL.md @@ -0,0 +1,172 @@ +# Raw Property Name Support — Test Design + +## Question 1: C# Compilation & System.Text.Json Behavior + +**Test:** Create class with raw snake_case property and `[JsonPropertyName]` attribute. + +```csharp +[JsonPropertyName("payMethod_SumBank")] +public double? payMethod_SumBank { get; set; } +``` + +**Expected:** Compiles without warnings; JSON deserialization maps correctly. + +**Result:** ✅ PASS — Tested on net10.0. Compilation succeeds. Serialization/deserialization verified with standalone console app. + +--- + +## Question 2: Edge Cases & Safety + +| Scenario | Valid C# ID? | Safe? | Mitigation | +|----------|------|------|-----------| +| Snake_case: `payMethod_SumBank` | ✅ Yes | ✅ Yes | None required | +| Kebab-case: `pay-method-sum` | ❌ No | ❌ No | Validation: reject or sanitize | +| Spaces: `pay method` | ❌ No | ❌ No | Validation: reject or sanitize | +| Reserved keyword: `class` | ✅ (with @) | ⚠️ Warn | Use `@class` prefix auto-insertion | +| Unicode: `café_name` | ✅ Yes | ✅ Yes | No action (UTF-16 native support) | + +**Key:** Must validate that OpenAPI-provided property names map to valid C# identifiers, or sanitize them. + +--- + +## Question 3: Recommended Test Suite + +If feature is enabled, add to `src/Refitter.Tests/Examples/RawPropertyNameGeneratorTests.cs`: + +### Test 1: Basic Generation +```csharp +[Test] +public async Task Can_Generate_Code_With_Raw_Property_Names() +{ + var spec = @"{ components: { schemas: { Item: { properties: { + payMethod_SumBank: { type: number } + }}}}}"; + var code = await GenerateCode(spec); + code.Should().NotBeNullOrWhiteSpace(); +} +``` + +### Test 2: Property Naming Verification +```csharp +[Test] +public async Task Generated_Code_Preserves_Raw_Property_Names() +{ + var code = await GenerateCode(spec); + code.Should().Contain("public double? payMethod_SumBank"); + code.Should().NotContain("public double? PayMethodSumBank"); +} +``` + +### Test 3: Compilation +```csharp +[Test] +public async Task Generated_Code_Compiles_With_Raw_Names() +{ + var code = await GenerateCode(spec); + BuildHelper.BuildCSharp(code).Should().BeTrue(); +} +``` + +### Test 4: Serialization Roundtrip +```csharp +[Test] +public async Task JsonSerializationRoundtrips_With_Raw_Property_Names() +{ + var code = await GenerateCode(spec); + var json = @"{""payMethod_SumBank"": 123.45}"; + + // Compile generated code + var dll = CompileCode(code); + + // Deserialize JSON + var obj = Deserialize(json, dll); + + // Serialize back + var json2 = Serialize(obj); + + // Verify JSON field name preserved + json2.Should().Contain("\"payMethod_SumBank\""); +} +``` + +### Test 5: Edge Cases +```csharp +[Test] +public async Task Invalid_Property_Names_With_Hyphens_Are_Rejected() +{ + var spec = @"{ components: { schemas: { Item: { properties: { + 'pay-method': { type: string } + }}}}}"; + + var action = async () => await GenerateCode(spec); + await action.Should().ThrowAsync() + .WithMessage("*invalid C# identifier*"); +} + +[Test] +public async Task Reserved_Keywords_Are_Escaped() +{ + var spec = @"{ components: { schemas: { Item: { properties: { + 'class': { type: string }, + 'return': { type: string } + }}}}}"; + + var code = await GenerateCode(spec); + code.Should().Contain("public string @class"); + code.Should().Contain("public string @return"); +} +``` + +--- + +## Current Configuration (Code State) + +| Entity | Status | File | +|--------|--------|------| +| `PropertyNameGenerator` property exists | ✅ Yes | `src/Refitter.Core/Settings/CodeGeneratorSettings.cs:264` | +| Marked `[JsonIgnore]` | ✅ Yes | `src/Refitter.Core/Settings/CodeGeneratorSettings.cs:265` | +| Excluded from serialization tests | ✅ Yes | `src/Refitter.Tests/SerializerTests.cs:36-37, 56-58, 72-73` | +| Used in factory | ✅ Yes | `src/Refitter.Core/CSharpClientGeneratorFactory.cs:33` | +| CLI option exists | ❌ No | N/A | +| JSON schema includes it | ⚠️ Yes (misleading) | `docs/json-schema.json` | + +--- + +## Validation Checklist (for Feature Implementation) + +- [ ] CLI option `--property-name-generator` or similar added to Settings +- [ ] JSON schema updated to remove or clarify `propertyNameGenerator` field +- [ ] SerializerTests updated to handle PropertyNameGenerator (or documented why excluded) +- [ ] RawPropertyNameGeneratorTests.cs added with 5+ test cases above +- [ ] Edge-case validation: hyphens, spaces, reserved keywords +- [ ] Compilation verified with BuildHelper +- [ ] JSON serialization roundtrip tested +- [ ] README updated with feature description and limitations +- [ ] All 1400+ existing tests still pass + +--- + +## Reusable Pattern — Refitter Setting Parity + +When a new `.refitter` enum setting lands, cover it in three layers: + +1. **Serializer binding:** add `SerializerTests` coverage for string enum serialization/deserialization. +2. **CLI mapping:** if the CLI mapping helper is private (for example `GenerateCommand.CreateRefitGeneratorSettings`), use reflection in tests to verify `Settings` → `RefitGeneratorSettings` mapping without changing production visibility. +3. **Source Generator parity:** add `AdditionalFiles\FeatureName.refitter` plus a focused resource spec, then assert generated members via reflection on compiled types instead of snapshotting entire files. + +For generated property-name assertions, prefer checking the identifier itself (string containment or reflection) over over-specifying nullability, because NSwag may emit non-nullable value types where tests might otherwise assume `?`. + +## Implemented Pattern — Issue #967 + +For user-facing property-name customization in Refitter: + +1. Expose a **serializable enum** (`PropertyNamingPolicy`) on `RefitGeneratorSettings` for CLI + `.refitter` parity instead of trying to serialize `IPropertyNameGenerator`. +2. Resolve precedence in `CSharpClientGeneratorFactory` as: + - `CodeGeneratorSettings.PropertyNameGenerator` (programmatic override), + - otherwise `PropertyNamingPolicy`. +3. For preserve-original behavior, generate identifiers by: + - returning already-valid identifiers unchanged, + - escaping reserved keywords with `@`, + - replacing invalid character runs with `_` and prefixing `_` when needed for compilable output, + - de-duplicating sibling collisions with `IdentifierUtils.Counted`. +4. Remove any JSON schema entry that exposes a non-serializable interface-based setting (`propertyNameGenerator`) once the enum replacement exists. diff --git a/README.md b/README.md index ebefa676..c5e0c24f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ EXAMPLES: refitter ./openapi.json --no-accept-headers refitter ./openapi.json --use-iso-date-format refitter ./openapi.json --additional-namespace "Your.Additional.Namespace" --additional-namespace "Your.Other.Additional.Namespace" + refitter ./openapi.json --property-naming-policy PreserveOriginal refitter ./openapi.json --multiple-interfaces ByEndpoint refitter ./openapi.json --tag Pet --tag Store --tag User refitter ./openapi.json --match-path '^/pet/.*' @@ -89,6 +90,7 @@ OPTIONS: -s, --settings-file Path to .refitter settings file. Specifying this will ignore all other settings (except for --output) -n, --namespace GeneratedCode Default namespace to use for generated types --contracts-namespace Default namespace to use for generated contracts + --property-naming-policy PascalCase Controls how generated contract properties are named. May be one of PascalCase, PreserveOriginal -o, --output Output.cs Path to Output file or folder (if multiple files are generated) --contracts-output Output path for generated contracts. Enabling this automatically enables generating multiple files --no-auto-generated-header Don't add header to output file @@ -197,6 +199,12 @@ This is particularly useful when: - Using tools that don't support ANSI escape sequences - Running in environments with limited console capabilities +### Property Naming Policy + +Generated contract properties default to `PascalCase`. To preserve valid OpenAPI field names such as `payMethod_SumBank`, use `--property-naming-policy PreserveOriginal` or set `"propertyNamingPolicy": "PreserveOriginal"` in a `.refitter` file. + +Refitter still emits `[JsonPropertyName]` attributes for serialization, escapes reserved C# keywords with `@`, and minimally sanitizes invalid identifiers into compilable names (for example `class` → `@class`, `123-value` → `_123_value`). + ## .Refitter File format @@ -207,6 +215,7 @@ The following is an example `.refitter` file using a single OpenAPI specificatio "openApiPath": "/path/to/your/openAPI", // Required if openApiPaths is not specified "namespace": "Org.System.Service.Api.GeneratedCode", // Optional. Default=GeneratedCode "contractsNamespace": "Org.System.Service.Api.GeneratedCode.Contracts", // Optional. Default=GeneratedCode + "propertyNamingPolicy": "PascalCase", // Optional. Values=PascalCase|PreserveOriginal. Default=PascalCase "naming": { "useOpenApiTitle": false, // Optional. Default=true "interfaceName": "MyApiClient" // Optional. Default=ApiClient @@ -234,7 +243,7 @@ The following is an example `.refitter` file using a single OpenAPI specificatio "generateDeprecatedOperations": false, // Optional. Default=true "operationNameTemplate": "{operationName}Async", // Optional. Must contain {operationName}. When multipleInterfaces == "ByEndpoint", this is name of the Execute() method in the interface where all instances of the string '{operationName}' is replaced with 'Execute' "optionalParameters": false, // Optional. Default=false - "outputFolder": "../CustomOutput" // Optional. Default=./Generated + "outputFolder": "../CustomOutput", // Optional. Default=./Generated "outputFilename": "RefitInterface.cs", // Optional. Default=Output.cs for CLI tool "contractsOutputFolder": "../Contracts", // Optional. Default=NULL "generateMultipleFiles": false, // Optional. Default=false @@ -343,6 +352,7 @@ The following is an example `.refitter` file using multiple OpenAPI specificatio - `openApiPath` - points to the OpenAPI Specifications file. This can be the path to a file stored on disk, relative to the `.refitter` file. This can also be a URL to a remote file that will be downloaded over HTTP/HTTPS. Required if `openApiPaths` is not specified. - `openApiPaths` - an array of paths to multiple OpenAPI Specifications files. When specified, the documents are merged into a single client. The first document in the array serves as the base; paths, component schemas, definitions (OpenAPI 2.x), and tags from subsequent documents are merged in. When duplicates exist (same path key or schema name), the first document's entry is preserved. Use this instead of `openApiPath` when you want to generate a single client from multiple API versions. Required if `openApiPath` is not specified. - `namespace` - the namespace used in the generated code. If not specified, this defaults to `GeneratedCode` +- `propertyNamingPolicy` - controls how generated contract properties are named. Possible values are `PascalCase` (default) and `PreserveOriginal`. `PreserveOriginal` keeps valid identifiers as-is, escapes reserved C# keywords with `@`, and minimally sanitizes invalid names into compilable identifiers while still emitting `[JsonPropertyName]` - `naming.useOpenApiTitle` - a boolean indicating whether the OpenApi title should be used. Default is `true` - `naming.interfaceName` - the name of the generated interface. The generated code will automatically prefix this with `I` so if this set to `MyApiClient` then the generated interface is called `IMyApiClient`. Default is `ApiClient` - `generateContracts` - a boolean indicating whether contracts should be generated. A use case for this is several API clients use the same contracts. Default is `true` diff --git a/docs/docfx_project/articles/refitter-file-format.md b/docs/docfx_project/articles/refitter-file-format.md index bf22d819..55dcfd4f 100644 --- a/docs/docfx_project/articles/refitter-file-format.md +++ b/docs/docfx_project/articles/refitter-file-format.md @@ -9,6 +9,7 @@ The following is an example `.refitter` file "openApiPath": "/path/to/your/openAPI", // Required if openApiPaths is not specified "namespace": "Org.System.Service.Api.GeneratedCode", // Optional. Default=GeneratedCode "contractsNamespace": "Org.System.Service.Api.GeneratedCode.Contracts", // Optional. Default=GeneratedCode + "propertyNamingPolicy": "PascalCase", // Optional. Values=PascalCase|PreserveOriginal. Default=PascalCase "naming": { "useOpenApiTitle": false, // Optional. Default=true "interfaceName": "MyApiClient" // Optional. Default=ApiClient @@ -150,6 +151,7 @@ When using `openApiPaths`, the documents are merged into a single generated clie - `openApiPaths` - a collection of paths to multiple OpenAPI Specifications files. When specified, the documents are merged into a single generated client. The first document wins on schema conflicts. Required if `openApiPath` is not specified - `namespace` - the namespace used in the generated code. If not specified, this defaults to `GeneratedCode` - `contractsNamespace` - the namespace used in the generated contracts. If not specified, this defaults to what `namespace` is set to +- `propertyNamingPolicy` - controls how generated contract properties are named. Possible values are `PascalCase` (default) and `PreserveOriginal`. `PreserveOriginal` keeps valid identifiers as-is, escapes reserved C# keywords with `@`, and minimally sanitizes invalid names into compilable identifiers while still emitting `[JsonPropertyName]` - `naming.useOpenApiTitle` - a boolean indicating whether the OpenApi title should be used. Default is `true` - `naming.interfaceName` - the name of the generated interface. The generated code will automatically prefix this with `I` so if this set to `MyApiClient` then the generated interface is called `IMyApiClient`. Default is `ApiClient` - `generateContracts` - a boolean indicating whether contracts should be generated. A use case for this is several API clients use the same contracts. Default is `true` @@ -614,9 +616,6 @@ When using `openApiPaths`, the documents are merged into a single generated clie }, "dateTimeFormat": { "type": "string" - }, - "propertyNameGenerator": { - "type": "object" } }, } diff --git a/docs/json-schema.json b/docs/json-schema.json index cfe04d7c..fc0c4f21 100644 --- a/docs/json-schema.json +++ b/docs/json-schema.json @@ -190,9 +190,6 @@ "description": "Gets or sets the date-time string format to use", "type": "string" }, - "propertyNameGenerator": { - "$ref": "#/definitions/IPropertyNameGenerator" - }, "customTemplateDirectory": { "description": "Custom directory with NSwag fluid templates for code generation. Default is null which uses the default NSwag templates. See https://github.com/RicoSuter/NSwag/wiki/Templates", "type": "string" @@ -248,10 +245,6 @@ "IParameterNameGenerator": { "type": "object" }, - "IPropertyNameGenerator": { - "description": "Gets or sets a custom IPropertyNameGenerator.", - "type": "object" - }, "NamingSettings": { "description": "The naming settings.", "type": "object", @@ -291,6 +284,14 @@ "naming": { "$ref": "#/definitions/NamingSettings" }, + "propertyNamingPolicy": { + "description": "Controls how generated contract properties are named. Default is PascalCase. Possible values: PascalCase, PreserveOriginal.", + "type": "string", + "enum": [ + "PascalCase", + "PreserveOriginal" + ] + }, "generateContracts": { "description": "Generate contracts. Default is true.", "type": "boolean" diff --git a/src/Refitter.Core/CSharpClientGeneratorFactory.cs b/src/Refitter.Core/CSharpClientGeneratorFactory.cs index 985c3429..d79c79a5 100644 --- a/src/Refitter.Core/CSharpClientGeneratorFactory.cs +++ b/src/Refitter.Core/CSharpClientGeneratorFactory.cs @@ -30,7 +30,7 @@ public CustomCSharpClientGenerator Create() GenerateDtoTypes = true, GenerateClientInterfaces = false, GenerateExceptionClasses = false, - CodeGeneratorSettings = { PropertyNameGenerator = settings.CodeGeneratorSettings?.PropertyNameGenerator ?? new CustomCSharpPropertyNameGenerator() }, + CodeGeneratorSettings = { PropertyNameGenerator = CreatePropertyNameGenerator() }, CSharpGeneratorSettings = { Namespace = settings.ContractsNamespace ?? settings.Namespace, @@ -73,6 +73,20 @@ public CustomCSharpClientGenerator Create() return generator; } + private IPropertyNameGenerator CreatePropertyNameGenerator() + { + if (settings.CodeGeneratorSettings?.PropertyNameGenerator is { } propertyNameGenerator) + { + return propertyNameGenerator; + } + + return settings.PropertyNamingPolicy switch + { + PropertyNamingPolicy.PreserveOriginal => new PreserveOriginalPropertyNameGenerator(), + _ => new CustomCSharpPropertyNameGenerator(), + }; + } + /// /// Converts schemas that use oneOf/anyOf with a discriminator to use the allOf inheritance /// pattern that NSwag's C# code generator understands. Without this transformation, diff --git a/src/Refitter.Core/IdentifierUtils.cs b/src/Refitter.Core/IdentifierUtils.cs index 40da096b..9756a9f0 100644 --- a/src/Refitter.Core/IdentifierUtils.cs +++ b/src/Refitter.Core/IdentifierUtils.cs @@ -1,7 +1,91 @@ +using System.Globalization; +using System.Text; + namespace Refitter.Core; internal static class IdentifierUtils { + private static readonly HashSet ReservedKeywords = new(StringComparer.Ordinal) + { + "abstract", + "as", + "base", + "bool", + "break", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "do", + "double", + "else", + "enum", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "goto", + "if", + "implicit", + "in", + "int", + "interface", + "internal", + "is", + "lock", + "long", + "namespace", + "new", + "null", + "object", + "operator", + "out", + "override", + "params", + "private", + "protected", + "public", + "readonly", + "ref", + "return", + "sbyte", + "sealed", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unsafe", + "ushort", + "using", + "virtual", + "void", + "volatile", + "while" + }; + /// /// Returns {value}{counter}{suffix} if {value}{name} exists in /// else returns {value}{name}. @@ -44,7 +128,7 @@ public static string Sanitize(this string value) if (string.IsNullOrEmpty(value)) return value; - // @ can be used and still make valid method names. but this should make most use cases safe + // @ can be used and still make valid method names, but this should make most use cases safe. if ( (value.First() < 'A' || value.First() > 'Z') && (value.First() < 'a' || value.First() > 'z') && @@ -57,6 +141,119 @@ public static string Sanitize(this string value) .Trim(dash); } + /// + /// Converts an arbitrary value into a compilable C# identifier while preserving as much of the original shape as + /// possible. + /// + /// The value to convert. + /// A compilable C# identifier. + public static string ToCompilableIdentifier(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "_"; + } + + var originalValue = value!; + + if (IsValidIdentifier(originalValue)) + { + return originalValue.StartsWith("@", StringComparison.Ordinal) + ? originalValue + : EscapeReservedKeyword(originalValue); + } + + var builder = new StringBuilder(originalValue.Length); + var previousCharacterWasUnderscore = false; + + foreach (var character in originalValue) + { + if (builder.Length == 0 && character == '@' && originalValue.Length > 1) + { + continue; + } + + if (IsValidIdentifierCharacter(character, builder.Length == 0)) + { + builder.Append(character); + previousCharacterWasUnderscore = false; + continue; + } + + if (builder.Length == 0) + { + builder.Append('_'); + previousCharacterWasUnderscore = true; + + if (IsValidIdentifierCharacter(character, false)) + { + builder.Append(character); + previousCharacterWasUnderscore = false; + } + + continue; + } + + if (!previousCharacterWasUnderscore) + { + builder.Append('_'); + previousCharacterWasUnderscore = true; + } + } + + var identifier = builder.Length == 0 ? "_" : builder.ToString(); + + if (!IsValidIdentifierCharacter(identifier[0], isFirstCharacter: true)) + { + identifier = "_" + identifier; + } + + return EscapeReservedKeyword(identifier); + } + + /// + /// Determines whether the supplied value is already a valid C# identifier. + /// + /// The value to inspect. + /// true when the value is already a valid identifier; otherwise, false. + public static bool IsValidIdentifier(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + var identifier = value!; + + if (identifier[0] == '@') + { + var unescapedIdentifier = identifier.Substring(1); + + return identifier.Length > 1 && + !IsReservedKeyword(unescapedIdentifier) && + IsValidIdentifierCore(unescapedIdentifier); + } + + return IsValidIdentifierCore(identifier); + } + + /// + /// Escapes reserved C# keywords with the verbatim identifier prefix. + /// + /// The identifier to escape. + /// The escaped identifier. + public static string EscapeReservedKeyword(string value) + { + if (string.IsNullOrEmpty(value) || value.StartsWith("@", StringComparison.Ordinal)) + { + return value; + } + + return IsReservedKeyword(value) + ? "@" + value + : value; + } + /// /// Sanitizes and formats controller tags for identifier usage. /// @@ -66,4 +263,54 @@ public static string SanitizeControllerTag(this string tag) { return tag.Sanitize().CapitalizeFirstCharacter(); } + + private static bool IsValidIdentifierCore(string value) + { + if (!IsValidIdentifierCharacter(value[0], isFirstCharacter: true)) + { + return false; + } + + for (var index = 1; index < value.Length; index++) + { + if (!IsValidIdentifierCharacter(value[index], isFirstCharacter: false)) + { + return false; + } + } + + return true; + } + + private static bool IsReservedKeyword(string value) => + ReservedKeywords.Contains(value); + + private static bool IsValidIdentifierCharacter(char value, bool isFirstCharacter) + { + if (value == '_') + { + return true; + } + + var category = char.GetUnicodeCategory(value); + + return isFirstCharacter + ? category is UnicodeCategory.UppercaseLetter + or UnicodeCategory.LowercaseLetter + or UnicodeCategory.TitlecaseLetter + or UnicodeCategory.ModifierLetter + or UnicodeCategory.OtherLetter + or UnicodeCategory.LetterNumber + : category is UnicodeCategory.UppercaseLetter + or UnicodeCategory.LowercaseLetter + or UnicodeCategory.TitlecaseLetter + or UnicodeCategory.ModifierLetter + or UnicodeCategory.OtherLetter + or UnicodeCategory.LetterNumber + or UnicodeCategory.DecimalDigitNumber + or UnicodeCategory.ConnectorPunctuation + or UnicodeCategory.NonSpacingMark + or UnicodeCategory.SpacingCombiningMark + or UnicodeCategory.Format; + } } diff --git a/src/Refitter.Core/PreserveOriginalPropertyNameGenerator.cs b/src/Refitter.Core/PreserveOriginalPropertyNameGenerator.cs new file mode 100644 index 00000000..6837ead8 --- /dev/null +++ b/src/Refitter.Core/PreserveOriginalPropertyNameGenerator.cs @@ -0,0 +1,10 @@ +using NJsonSchema; +using NJsonSchema.CodeGeneration; + +namespace Refitter.Core; + +internal class PreserveOriginalPropertyNameGenerator : IPropertyNameGenerator +{ + public string Generate(JsonSchemaProperty property) => + IdentifierUtils.ToCompilableIdentifier(property.Name); +} diff --git a/src/Refitter.Core/Settings/PropertyNamingPolicy.cs b/src/Refitter.Core/Settings/PropertyNamingPolicy.cs new file mode 100644 index 00000000..8c37ce0a --- /dev/null +++ b/src/Refitter.Core/Settings/PropertyNamingPolicy.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Refitter.Core; + +/// +/// Controls how generated contract property names are emitted. +/// +public enum PropertyNamingPolicy +{ + /// + /// Convert OpenAPI property names to PascalCase C# property names. + /// + [Description("Convert OpenAPI property names to PascalCase C# property names.")] + PascalCase, + + /// + /// Preserve the original OpenAPI property name when possible and minimally sanitize invalid C# identifiers. + /// + [Description("Preserve original OpenAPI property names when possible and minimally sanitize invalid C# identifiers.")] + PreserveOriginal, +} diff --git a/src/Refitter.Core/Settings/RefitGeneratorSettings.cs b/src/Refitter.Core/Settings/RefitGeneratorSettings.cs index 71572b3b..cbf0996c 100644 --- a/src/Refitter.Core/Settings/RefitGeneratorSettings.cs +++ b/src/Refitter.Core/Settings/RefitGeneratorSettings.cs @@ -52,6 +52,13 @@ public class RefitGeneratorSettings [Description("The naming settings.")] public NamingSettings Naming { get; set; } = new(); + /// + /// Gets or sets how generated contract properties are named. + /// + [Description("Controls how generated contract properties are named. Default is PascalCase. Possible values: PascalCase, PreserveOriginal.")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PropertyNamingPolicy PropertyNamingPolicy { get; set; } = PropertyNamingPolicy.PascalCase; + /// /// Gets or sets a value indicating whether contracts should be generated. /// diff --git a/src/Refitter.SourceGenerator.Tests/Refitter.SourceGenerator.Tests.csproj b/src/Refitter.SourceGenerator.Tests/Refitter.SourceGenerator.Tests.csproj index 4884bd72..dede9f27 100644 --- a/src/Refitter.SourceGenerator.Tests/Refitter.SourceGenerator.Tests.csproj +++ b/src/Refitter.SourceGenerator.Tests/Refitter.SourceGenerator.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Refitter.SourceGenerator.Tests/Resources/PropertyNamingPolicy.json b/src/Refitter.SourceGenerator.Tests/Resources/PropertyNamingPolicy.json new file mode 100644 index 00000000..49660f99 --- /dev/null +++ b/src/Refitter.SourceGenerator.Tests/Resources/PropertyNamingPolicy.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Property Naming Policy API", + "version": "1.0.0" + }, + "paths": { + "/payments": { + "get": { + "operationId": "GetPayment", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PaymentResponse": { + "type": "object", + "properties": { + "payMethod_SumBank": { + "type": "number", + "format": "double" + } + } + } + } + } +} diff --git a/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs b/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs new file mode 100644 index 00000000..ddad6dd1 --- /dev/null +++ b/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using Refitter.Core; +using Refitter.Tests.Build; +using Refitter.Tests.TestUtilities; +using TUnit.Core; + +namespace Refitter.Tests.Examples; + +public class PropertyNamingPolicyTests +{ + private const string OpenApiSpec = """ + { + "openapi": "3.0.1", + "info": { + "title": "Property Naming Policy API", + "version": "1.0.0" + }, + "paths": { + "/payments": { + "get": { + "operationId": "GetPayment", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PaymentResponse": { + "type": "object", + "properties": { + "payMethod_SumBank": { + "type": "number", + "format": "double" + }, + "class": { + "type": "string" + }, + "1st-payment-method": { + "type": "string" + } + } + } + } + } + } + """; + + [Test] + public async Task Can_Generate_Code_With_Default_PascalCase_Property_Naming() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task Default_PascalCase_Property_Naming_Remains_Unchanged() + { + string generatedCode = await GenerateCode(); + generatedCode.Should().Contain("public double PayMethodSumBank { get; set; }"); + generatedCode.Should().Contain("""[JsonPropertyName("payMethod_SumBank")]"""); + } + + [Test] + public async Task PreserveOriginal_Emits_Raw_Valid_Identifiers() + { + string generatedCode = await GenerateCode(PropertyNamingPolicy.PreserveOriginal); + generatedCode.Should().Contain("public double payMethod_SumBank { get; set; }"); + } + + [Test] + public async Task PreserveOriginal_Escapes_Reserved_Keywords() + { + string generatedCode = await GenerateCode(PropertyNamingPolicy.PreserveOriginal); + generatedCode.Should().Contain("public string @class { get; set; }"); + } + + [Test] + public async Task PreserveOriginal_Minimally_Sanitizes_Invalid_Identifiers() + { + string generatedCode = await GenerateCode(PropertyNamingPolicy.PreserveOriginal); + generatedCode.Should().Contain("_1st_payment_method { get; set; }"); + } + + [Test] + public async Task PreserveOriginal_Generated_Code_Can_Build() + { + string generatedCode = await GenerateCode(PropertyNamingPolicy.PreserveOriginal); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + private static async Task GenerateCode( + PropertyNamingPolicy propertyNamingPolicy = PropertyNamingPolicy.PascalCase) + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(OpenApiSpec); + + try + { + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + PropertyNamingPolicy = propertyNamingPolicy, + }; + + var sut = await RefitGenerator.CreateAsync(settings); + return sut.Generate(); + } + finally + { + if (File.Exists(swaggerFile)) + { + File.Delete(swaggerFile); + } + + var directory = Path.GetDirectoryName(swaggerFile); + if (directory != null && Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } +} diff --git a/src/Refitter.Tests/GenerateCommandTests.cs b/src/Refitter.Tests/GenerateCommandTests.cs index 0996610a..273244b3 100644 --- a/src/Refitter.Tests/GenerateCommandTests.cs +++ b/src/Refitter.Tests/GenerateCommandTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using FluentAssertions; using Refitter.Core; using TUnit.Core; @@ -99,6 +100,30 @@ public void Validate_Should_Succeed_For_Valid_OperationNameTemplate() result.Successful.Should().BeTrue(); } + [Test] + public void CreateRefitGeneratorSettings_Should_Map_PropertyNamingPolicy() + { + var settings = new Settings + { + OpenApiPath = "https://example.com/openapi.json", + PropertyNamingPolicy = PropertyNamingPolicy.PreserveOriginal + }; + + var method = typeof(GenerateCommand).GetMethod( + "CreateRefitGeneratorSettings", + BindingFlags.NonPublic | BindingFlags.Static); + + method.Should().NotBeNull(); + + var refitSettings = method! + .Invoke(null, [settings]) + .Should() + .BeOfType() + .Subject; + + refitSettings.PropertyNamingPolicy.Should().Be(PropertyNamingPolicy.PreserveOriginal); + } + [Test] public void Command_Should_Have_Public_Validate_Method() { diff --git a/src/Refitter.Tests/SerializerTests.cs b/src/Refitter.Tests/SerializerTests.cs index 04389829..7818a512 100644 --- a/src/Refitter.Tests/SerializerTests.cs +++ b/src/Refitter.Tests/SerializerTests.cs @@ -82,6 +82,15 @@ public void Can_Deserialize_CodeGeneratorSettings_With_IntegerType_String_Int64( settings.IntegerType.Should().Be(IntegerType.Int64); } + [Test] + public void Can_Deserialize_RefitGeneratorSettings_With_PropertyNamingPolicy_String_PreserveOriginal() + { + const string json = """{"propertyNamingPolicy": "PreserveOriginal"}"""; + var settings = Serializer.Deserialize(json); + settings.Should().NotBeNull(); + settings.PropertyNamingPolicy.Should().Be(PropertyNamingPolicy.PreserveOriginal); + } + [Test] public void Can_Deserialize_CodeGeneratorSettings_With_IntegerType_String_Int32() { @@ -107,4 +116,15 @@ public void Can_Serialize_CodeGeneratorSettings_With_IntegerType() var json = Serializer.Serialize(settings); json.Should().Contain("\"integerType\": \"Int64\""); } + + [Test] + public void Can_Serialize_RefitGeneratorSettings_With_PropertyNamingPolicy() + { + var settings = CreateTestSettings(); + settings.PropertyNamingPolicy = PropertyNamingPolicy.PreserveOriginal; + + var json = Serializer.Serialize(settings); + + json.Should().Contain("\"propertyNamingPolicy\": \"PreserveOriginal\""); + } } diff --git a/src/Refitter.Tests/SettingsTests.cs b/src/Refitter.Tests/SettingsTests.cs index fe1fb9a3..851cd1d0 100644 --- a/src/Refitter.Tests/SettingsTests.cs +++ b/src/Refitter.Tests/SettingsTests.cs @@ -57,6 +57,7 @@ public void Default_Values_Should_Be_Set_Correctly() settings.NoInlineJsonConverters.Should().BeFalse(); settings.IntegerType.Should().Be(IntegerType.Int32); settings.CustomTemplateDirectory.Should().BeNull(); + settings.PropertyNamingPolicy.Should().Be(PropertyNamingPolicy.PascalCase); } [Test] @@ -193,6 +194,16 @@ public void Should_Allow_Setting_IntegerType() settings.IntegerType.Should().Be(IntegerType.Int32); } + [Test] + public void Should_Allow_Setting_PropertyNamingPolicy() + { + var settings = new Settings { PropertyNamingPolicy = PropertyNamingPolicy.PreserveOriginal }; + settings.PropertyNamingPolicy.Should().Be(PropertyNamingPolicy.PreserveOriginal); + + settings.PropertyNamingPolicy = PropertyNamingPolicy.PascalCase; + settings.PropertyNamingPolicy.Should().Be(PropertyNamingPolicy.PascalCase); + } + [Test] public void Should_Allow_Setting_String_Arrays() { diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index b72a3344..507f027f 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -281,6 +281,7 @@ private static RefitGeneratorSettings CreateRefitGeneratorSettings(Settings sett { OpenApiPath = settings.OpenApiPath!, Namespace = settings.Namespace ?? "GeneratedCode", + PropertyNamingPolicy = settings.PropertyNamingPolicy, AddAutoGeneratedHeader = !settings.NoAutoGeneratedHeader, AddAcceptHeaders = !settings.NoAcceptHeaders, GenerateContracts = !settings.InterfaceOnly, diff --git a/src/Refitter/Settings.cs b/src/Refitter/Settings.cs index d401603a..8d142e7a 100644 --- a/src/Refitter/Settings.cs +++ b/src/Refitter/Settings.cs @@ -27,6 +27,14 @@ public sealed class Settings : CommandSettings [DefaultValue(null)] public string? ContractsNamespace { get; set; } + private const string PropertyNamingPolicyValues = + $"{nameof(Core.PropertyNamingPolicy.PascalCase)}, {nameof(Core.PropertyNamingPolicy.PreserveOriginal)}"; + + [Description($"Controls how generated contract properties are named. May be one of {PropertyNamingPolicyValues}")] + [CommandOption("--property-naming-policy")] + [DefaultValue(Core.PropertyNamingPolicy.PascalCase)] + public Core.PropertyNamingPolicy PropertyNamingPolicy { get; set; } = Core.PropertyNamingPolicy.PascalCase; + [Description("Path to Output file or folder (if multiple files are generated)")] [CommandOption("-o|--output")] [DefaultValue(DefaultOutputPath)]