Skip to content

feat: Generate nested resource classes for complex object properties - #542

Merged
razor-x merged 7 commits into
mainfrom
claude/acs-credential-namespacing-l3q9m3
Aug 12, 2026
Merged

razor-x merged 7 commits into
mainfrom
claude/acs-credential-namespacing-l3q9m3

Conversation

@razor-x

@razor-x razor-x commented Aug 12, 2026

Copy link
Copy Markdown
Member

This change implements automatic generation of nested Ruby classes for complex object properties in Seam API resources, replacing inline hash accessors with strongly-typed resource objects.

Summary

The code generation system now creates nested classes for object-type properties within resources, providing better type safety and IDE support. Properties that were previously accessed as hashes through DeepHashAccessor are now represented as proper Ruby resource classes with their own attributes and nested structures.

Key Changes

  • Code Generation Updates (codegen/lib/layouts/resource.ts):

    • Added ResourceClass interface to represent nested class structures
    • Implemented buildClass() function to recursively generate nested classes with proper indentation and scoping
    • Added logic to detect and handle nested objects, lists of objects, and discriminated unions
    • Implemented depth limiting (max 16 levels) to prevent infinite recursion on cyclic schemas
    • Added reserved class name checking to prevent shadowing of BaseResource, Resources, and Seam
  • Resource Base Class (lib/seam/base_resource.rb):

    • Added resource_accessor and resource_list_accessor class methods to register nested resource types
    • Added [] operator for hash-like subscript access to resource attributes
    • Modified load_from_response() to handle nil values gracefully
    • Added automatic conversion of nested objects to their corresponding resource classes
  • Resource Classes (Device, AcsEntrance, AcsUser, AccessCode, etc.):

    • Generated nested classes for all complex object properties (e.g., Device::Properties, Device::Location, Device::DeviceManufacturer)
    • Each nested class includes proper attr_accessor declarations and resource_accessor/resource_list_accessor registrations
    • Preserved all documentation and deprecation notices from the schema
  • Template Updates (codegen/layouts/resource.hbs, codegen/layouts/partials/resource-class.hbs):

    • Added partial template for rendering nested resource classes with proper indentation
    • Integrated nested class generation into main resource template
  • Support Modules (lib/seam/resources/resource_errors_support.rb, resource_warnings_support.rb):

    • Added update_from_response() override to reset cached converted values when data changes
  • Tests:

    • Updated hash handling tests to verify nested objects use generated resource classes
    • Added tests for nil response handling and invalid attribute name skipping
    • Added test for DeepHashAccessor value reuse

Implementation Details

The nested class generation respects the resource hierarchy and creates classes at appropriate nesting levels. For example, Device::Properties::Battery is generated as a nested class within Device::Properties, which is itself nested within Device. The system automatically handles:

  • Discriminated unions by merging properties from all variants
  • List properties by creating resource list accessors
  • Proper indentation for Ruby syntax
  • Deprecation notices and documentation preservation

https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n

razor-x and others added 4 commits August 5, 2026 22:07
Nested resource classes were emitted at the top of `Seam::Resources` with
their root resource name flattened onto the property name, so
`acs_credential.assa_abloy_vostio_metadata` became
`Seam::Resources::AcsCredentialAssaAbloyVostioMetadata`.

Two problems with that. It leaks every nested shape into the
`Seam::Resources` namespace, and because the name only ever used the root
resource name, distinct shapes at different depths collapsed onto one
class. The first shape reached won, and the rest silently inherited the
wrong accessors:

  - `device.properties.battery` took the shape of
    `device.properties.accessory_keypad.battery`, dropping `status`.
  - `device.properties.available_climate_presets[].ecobee_metadata` (and
    the `current_` / `default_climate_setting` variants) took the shape of
    `device.properties.ecobee_metadata`, dropping `climate_ref`,
    `is_optimized`, and `owner`.
  - the `phone_session` credential/entrance `assa_abloy_vostio_metadata`
    and `visionline_metadata` pairs collapsed onto each other.

Emit nested classes inside the class that owns them instead, named after
the property alone, so the enclosing scope disambiguates them:

  Seam::Resources::AcsCredential::AssaAbloyVostioMetadata
  Seam::Resources::Device::Properties::Battery
  Seam::Resources::Device::Properties::AccessoryKeypad::Battery

That removes all seven collisions by construction and keeps
`Seam::Resources` down to the resources themselves. The generator now
builds a class tree rather than a flat map, and raises if a nested class
would shadow a constant the generated code depends on, if two siblings
would claim the same name, or if the shape nests implausibly deep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n
@razor-x
razor-x marked this pull request as ready for review August 12, 2026 06:21
claude added 3 commits August 12, 2026 07:07
`mergeProperties` kept the first occurrence of each property name and
discarded the rest. That was invisible while merged shapes stayed hashes,
but this branch turns them into typed classes, so the dropped fields
became unreachable:

  - `ActionAttempt::Result` carried one field, `was_confirmed_by_device`.
    An `ENCODE_ACS_CREDENTIAL` attempt's `acs_credential_on_encoder` and
    `acs_credential_on_seam`, and a `CREATE_INSTANT_KEY` attempt's
    `instant_key_url`, had no accessor at all.
  - `AcsUser::PendingMutations::From` carried three of its eight fields,
    and the same pattern hit `from`/`to` across ten resources.

Merge recursively and take the union instead, descending into nested
objects and list items so a field is kept no matter how deep the variants
disagree. Variants of a nested discriminated list are concatenated and
merged by whoever consumes the list.

The merged class is wider than any single variant, which is the tradeoff
for one class per union: a `LOCK_DOOR` result answers `instant_key_url`
with nil. Previously the field was absent entirely, so this trades a
missing accessor for a nil one and stops losing response data.

Merging now raises if variants disagree on a property's shape rather than
silently picking one. The only disagreements in the blueprint today are
`action_attempt.error.type` and `result.errors[].error_code`, both string
against enum, which generate the same accessor; anything structural fails
generation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n
Merging kept the first occurrence's description along with its shape. Each
variant documents a property for its own case, so the surviving text
describes one arm of a union the class no longer distinguishes. Of 271
merged properties, 82 had conflicting descriptions.

Most were merely narrow: `from` on an access code's pending mutations read
"Previous code configuration" on a shape that also covers names. Four were
wrong outright, describing the opposite of what the field means:

    # Indicates that the error is not a device error.
    attr_accessor :is_device_error

Keep a description only when every variant that documents the property
agrees, which also picks the real text when the others are blank. Deprecate
the merged property if any variant deprecates it, so a warning is never
lost to a variant that omits it.

The per-field documentation inside a merged class is unaffected, since
those fields come from a single variant each. Only the summary of a shape
the variants describe differently goes away.

The type packages document each variant correctly and the TypeScript SDK
renders them as a union, so it keeps the precise text. Resolving this
upstream would flatten those descriptions and cost that accuracy, so the
loss stays here, in the SDKs that merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n
Errors and warnings were the only properties the generator skipped. At the
top level of a resource they were served by hand-written ResourceError and
ResourceWarning classes carrying just error_code/warning_code, message and
created_at — the intersection of every variant — so a field only some
variants send was unreachable: access_grant.warnings sends device_id and
access_code.errors sends managed_access_code_id, but reading either raised
NoMethodError. Nested occurrences fared worse. The skip applied at every
depth, so device.properties.active_thermostat_schedule.errors fell through
to a plain accessor and arrived as raw hashes with string keys, with no
resource class at all.

Generate them as ordinary nested resource classes instead. Each resource
gets its own Errors and Warnings scoped to it, carrying the union of every
variant's fields, exactly like the other discriminated shapes.

resource_list_accessor now reads as an empty array when the response omits
the key, sends null, or sends some other type, which is what the support
modules guaranteed for errors and warnings and is worth keeping for every
list rather than handing callers a nil to guard.

BREAKING CHANGE: Seam::Resources::ResourceError,
Seam::Resources::ResourceWarning, Seam::Resources::ResourceErrorsSupport and
Seam::Resources::ResourceWarningsSupport are gone. Errors and warnings are
now instances of the owning resource's own class, so code matching on
ResourceError should match on e.g. Seam::Resources::Device::Errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n
@razor-x razor-x changed the title Generate nested resource classes for complex object properties feat: Generate nested resource classes for complex object properties Aug 12, 2026
@razor-x
razor-x merged commit 3ac634c into main Aug 12, 2026
17 checks passed
@razor-x
razor-x deleted the claude/acs-credential-namespacing-l3q9m3 branch August 12, 2026 16:15
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