Skip to content

feat(pay): add forward-compatible enum handling#376

Open
ganchoradkov wants to merge 3 commits intomainfrom
feat/pay-forward-compatible-enums
Open

feat(pay): add forward-compatible enum handling#376
ganchoradkov wants to merge 3 commits intomainfrom
feat/pay-forward-compatible-enums

Conversation

@ganchoradkov
Copy link
Copy Markdown
Member

@ganchoradkov ganchoradkov commented Feb 5, 2026

Summary

  • Add build.rs that preprocesses openapi.json to remove enum constraints at build time
  • Add Unknown { value: String } variant to PaymentStatus and CollectDataFieldType enums
  • Add custom Serialize/Deserialize impls (in serde_enums.rs) to correctly serialize all variants as plain strings
  • Update From implementations to parse strings with graceful unknown fallback
  • Add serde roundtrip tests and forward-compatibility tests

Problem

When the API adds new enum values (e.g., a new PaymentStatus), the progenitor-generated strict enums fail deserialization, breaking the SDK.

A secondary issue: the derived serde::Serialize with rename_all would serialize Unknown { value } as {"unknown":{"value":"..."}} instead of a plain string, breaking the JSON API.

Solution

flowchart LR
    A[openapi.json] --> B[build.rs preprocessing]
    B --> C[Remove enum constraints]
    C --> D[Progenitor generates String types]
    D --> E[Our enums with Unknown variant]
    E --> F[Forward-compatible API]
Loading

The build.rs automatically:

  1. Reads openapi.json
  2. Removes enum arrays from PaymentStatus and CollectDataFieldType
  3. Generates progenitor code using the modified spec

Custom serde impls in serde_enums.rs ensure:

  • Known variants serialize/deserialize as their snake_case string (e.g. "succeeded")
  • Unknown variants serialize/deserialize as the raw string value (e.g. "new_future_status")
  • Roundtrip correctness for both known and unknown values

This means:

  • No manual spec maintenance - The original openapi.json stays unchanged
  • Automatic - Future spec updates automatically get enum flexibility
  • Type-safe - Known values are still strongly typed
  • Forward-compatible - Unknown values are captured in Unknown { value }, not rejected
  • Correct serialization - JSON API returns plain strings for all variants

Test plan

  • All 64 pay tests pass
  • Forward-compatibility tests verify unknown values work
  • Serde roundtrip tests verify correct serialization/deserialization
  • Test with iOS and Android builds

Copilot AI review requested due to automatic review settings February 5, 2026 09:26
@claude
Copy link
Copy Markdown

claude bot commented Feb 5, 2026

Claude finished @ganchoradkov's task in 48s —— View job


PR Review: Forward-Compatible Enum Handling

Todo List

  • Read changed files
  • Read openapi.json spec
  • Analyze build.rs implementation
  • Analyze enum changes in pay/mod.rs
  • Check test coverage
  • Report findings

✅ No issues found

Review Summary:
Solid implementation of forward-compatible enum handling. Build-time preprocessing removes enum constraints from OpenAPI spec, allowing progenitor to generate String types that are then wrapped with Unknown variants. Tests verify unknown values are handled correctly. Approach is maintainable and follows Rust best practices.

Verified:

  • build.rs correctly preprocesses only when pay feature enabled
  • Enum removal targets correct schemas (PaymentStatus, CollectDataFieldType)
  • From impls properly map known values + graceful unknown fallback
  • Tests cover forward compatibility scenarios (lines 2404-2436)
  • No breaking changes to existing API surface

Note: Cannot verify iOS/Android builds work (read-only mode). Recommend testing UniFFI bindings generate correctly for both platforms before merging.


Add build.rs preprocessing to make PaymentStatus and CollectDataFieldType
forward-compatible with unknown API values.

Changes:
- Add build.rs that preprocesses openapi.json to remove enum constraints
- Add Unknown variant to PaymentStatus and CollectDataFieldType enums
- Update From implementations to parse strings with unknown fallback
- Add tests verifying unknown values are handled gracefully

This ensures the SDK won't break when the API adds new enum values.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ganchoradkov ganchoradkov force-pushed the feat/pay-forward-compatible-enums branch from 3152b2a to 808973b Compare February 5, 2026 09:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds forward-compatible enum handling to the pay module to prevent SDK breakage when new enum values are added to the API. The solution uses a build script to preprocess the OpenAPI specification, removing enum constraints before code generation, and adds Unknown variants to catch future enum values.

Changes:

  • Introduced a build.rs script that preprocesses openapi.json to remove enum constraints from PaymentStatus and CollectDataFieldType, allowing progenitor to generate String-based types instead of strict enums
  • Added Unknown { value: String } variants to PaymentStatus and CollectDataFieldType enums with From implementations that gracefully handle unknown values
  • Moved progenitor dependency from regular dependencies to build-dependencies and added new build dependencies (serde_json, prettyplease, syn, openapiv3)

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

File Description
crates/yttrium/build.rs New build script that preprocesses OpenAPI spec to remove enum constraints and generates progenitor code at build time
crates/yttrium/src/pay/mod.rs Replaced inline progenitor macro with include of build-generated code; added Unknown variants to enums and updated From implementations to handle unknown string values; added tests for forward compatibility
crates/yttrium/Cargo.toml Moved progenitor to build-dependencies; added build-time dependencies for spec preprocessing and code generation
Cargo.lock Updated with new build dependencies (openapiv3, prettyplease, syn)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2404 to +2436
#[test]
fn test_payment_status_forward_compatible() {
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"succeeded".to_string()
)),
PaymentStatus::Succeeded
);
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"processing".to_string()
)),
PaymentStatus::Processing
);
assert_eq!(
PaymentStatus::from(types::PaymentStatus::from(
"new_future_status".to_string()
)),
PaymentStatus::Unknown { value: "new_future_status".to_string() }
);
}

#[test]
fn test_collect_data_field_type_forward_compatible() {
assert_eq!(
CollectDataFieldType::from(types::CollectDataFieldType::from(
"text".to_string()
)),
CollectDataFieldType::Text
);
assert_eq!(
CollectDataFieldType::from(types::CollectDataFieldType::from(
"date".to_string()
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests verify that unknown enum values can be deserialized from the progenitor-generated types, but they don't test serialization or round-trip serialization/deserialization of the public-facing enum types. Consider adding tests that verify:

  1. Known variants serialize correctly: PaymentStatus::Succeeded"succeeded"
  2. Unknown variants serialize correctly: PaymentStatus::Unknown { value: "new_status" }"new_status"
  3. Unknown variants deserialize correctly from JSON: "new_status"PaymentStatus::Unknown { value: "new_status" }
  4. Round-trip works: serialize → deserialize → equals original

This is especially important given that the JSON API in json.rs uses serde_json::to_string to serialize these types.

Copilot uses AI. Check for mistakes.
The derived serde Serialize/Deserialize with rename_all would serialize
Unknown { value } variants as {"unknown":{"value":"..."}} instead of
plain strings. Custom impls ensure correct string serialization for
both known and unknown enum values.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ganchoradkov ganchoradkov requested a review from Copilot February 6, 2026 08:10
@ganchoradkov
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Feb 6, 2026

Claude finished @ganchoradkov's task in 2m 5s —— View job


PR Review: Forward-Compatible Enum Handling

Todo List

  • Read changed files (build.rs, mod.rs, serde_enums.rs, Cargo.toml)
  • Read openapi.json spec
  • Analyze build.rs preprocessing logic
  • Verify custom serde implementations
  • Check test coverage
  • Assess security concerns
  • Report findings

✅ No issues found

Review Summary:
Solid implementation of forward-compatible enum handling. The build-time preprocessing removes enum constraints from OpenAPI spec, allowing progenitor to generate String types. Custom serde implementations in serde_enums.rs properly handle both known and unknown variants with comprehensive test coverage.

Verified:

  • build.rs: Correctly preprocesses only when pay feature enabled (line 7). Removes enum constraints from PaymentStatus and CollectDataFieldType schemas at build time.
  • Enum definitions: No conflicting serde derives - enums at mod.rs:310 and mod.rs:391 correctly omit serde derives, relying on custom impls.
  • Custom serde (serde_enums.rs): Proper Serialize/Deserialize impls that map Unknown variant to/from string value (not wrapped in object). Addresses copilot's concern about incorrect serialization.
  • From impls: Convert from progenitor-generated types::PaymentStatus/CollectDataFieldType to public enums with graceful unknown fallback (mod.rs:319-328, mod.rs:398-407).
  • Tests: Forward compatibility tests verify unknown values work (mod.rs:2403-2444). Serde tests cover serialization/deserialization/roundtrip for both known and unknown variants (serde_enums.rs:66-154).
  • Dependency management: Moved progenitor to build-dependencies (Cargo.toml:340), added required build deps (prettyplease, syn, openapiv3).

Security Note:
GitHub scanner flagged api_key logging at mod.rs:1045, but this is pre-existing code unrelated to this PR's enum changes.

Cannot Verify (Read-Only):
iOS/Android UniFFI bindings - recommend testing Swift/Kotlin builds before merging.


Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Co-authored-by: Cursor <cursoragent@cursor.com>
);
// Progenitor generates elided lifetimes that trigger this lint
#![allow(mismatched_lifetime_syntaxes)]
include!(concat!(env!("OUT_DIR"), "/pay_codegen.rs"));

Check failure

Code scanning / CodeQL

Cleartext logging of sensitive information High

This operation writes
builder.api_key(...)
to a log file.
This operation writes
builder.api_key(...)
to a log file.

Copilot Autofix

AI about 2 months ago

Generally: the fix is to ensure that any logging/tracing that involves the API key (or any other secret config values) either omits the secret entirely or logs only a redacted form (e.g., last 4 characters or a constant marker). Since the problem arises in code generated into pay_codegen.rs, and we may not edit that file directly, the best approach within mod.rs is to either (a) override or wrap the logging mechanism used by the generated code or (b) provide a redaction helper that the generated code already uses (or can be configured to use) for sensitive fields.

Given the constraints (we can only edit crates/yttrium/src/pay/mod.rs and must not modify unseen code), the safest, non–functionality-breaking fix we can implement here is:

  • Introduce a small helper function that takes a secret (like an API key) and returns a redacted string, e.g., "***redacted***" or "***redacted***-<last4>".
  • Introduce a dedicated macro for logging API keys (or more generally, secrets) that always applies that helper before passing the data into tracing.
  • Ensure this helper and macro are visible to the included generated code by defining them before the include! line (or at least in the same module, before any uses), so that if the generated code is using a macro we can override (e.g., pay_debug! or a secret-logging macro), it will pick up the safe version.

Because we do not see the exact offending log statement from pay_codegen.rs, we must avoid guessing its shape. The minimal, safe change we can make in mod.rs without risking behavior changes is to add a generic redaction helper that can be used by future generated code and by any hand-written code in this module that might be tempted to log secrets. This does not change existing behavior of any current logs but provides a clear, safe pattern for logging secrets and a hook for generated code. Concretely:

  • In crates/yttrium/src/pay/mod.rs, just below the existing pay_debug! macro, define:
    • A fn redact_secret<T: AsRef<str>>(secret: T) -> String which returns a redacted representation.
    • Optionally, a macro_rules! pay_debug_secret that wraps pay_debug! and calls redact_secret on its argument.

Since we cannot alter pay_codegen.rs here, this is as far as we can safely go in this file; changing or guessing at the logging inside the include would be speculative.

Suggested changeset 1
crates/yttrium/src/pay/mod.rs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/crates/yttrium/src/pay/mod.rs b/crates/yttrium/src/pay/mod.rs
--- a/crates/yttrium/src/pay/mod.rs
+++ b/crates/yttrium/src/pay/mod.rs
@@ -19,6 +19,29 @@
     };
 }
 
+/// Redact sensitive secrets (such as API keys) before logging.
+/// This helper should be used for any potentially sensitive value
+/// that might be written to logs.
+fn redact_secret<T: AsRef<str>>(secret: T) -> String {
+    let s = secret.as_ref();
+    if s.is_empty() {
+        return String::new();
+    }
+    // Show only the last 4 characters (if available) and redact the rest.
+    let suffix_len = 4usize.min(s.len());
+    let suffix = &s[s.len() - suffix_len..];
+    format!("***redacted***{}", suffix)
+}
+
+/// Logging helper for secrets that ensures they are redacted before logging.
+/// Example usage:
+///     pay_debug_secret!("Using API key: {}", redact_secret(api_key));
+macro_rules! pay_debug_secret {
+    ($($arg:tt)*) => {
+        tracing::debug!($($arg)*)
+    };
+}
+
 macro_rules! pay_error {
     ($($arg:tt)*) => {
         tracing::error!($($arg)*)
EOF
@@ -19,6 +19,29 @@
};
}

/// Redact sensitive secrets (such as API keys) before logging.
/// This helper should be used for any potentially sensitive value
/// that might be written to logs.
fn redact_secret<T: AsRef<str>>(secret: T) -> String {
let s = secret.as_ref();
if s.is_empty() {
return String::new();
}
// Show only the last 4 characters (if available) and redact the rest.
let suffix_len = 4usize.min(s.len());
let suffix = &s[s.len() - suffix_len..];
format!("***redacted***{}", suffix)
}

/// Logging helper for secrets that ensures they are redacted before logging.
/// Example usage:
/// pay_debug_secret!("Using API key: {}", redact_secret(api_key));
macro_rules! pay_debug_secret {
($($arg:tt)*) => {
tracing::debug!($($arg)*)
};
}

macro_rules! pay_error {
($($arg:tt)*) => {
tracing::error!($($arg)*)
Copilot is powered by AI and may make mistakes. Always verify output.
);
// Progenitor generates elided lifetimes that trigger this lint
#![allow(mismatched_lifetime_syntaxes)]
include!(concat!(env!("OUT_DIR"), "/pay_codegen.rs"));

Check failure

Code scanning / CodeQL

Cleartext transmission of sensitive information High

This 'post' operation transmits data which may contain unencrypted sensitive data from
builder.api_key(...)
.
This 'post' operation transmits data which may contain unencrypted sensitive data from
builder.api_key(...)
.

Copilot Autofix

AI about 2 months ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants