Repository navigation
feat: Generate nested resource classes for complex object properties - #542
Merged
Merged
Conversation
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
marked this pull request as ready for review
August 12, 2026 06:21
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
DeepHashAccessorare now represented as proper Ruby resource classes with their own attributes and nested structures.Key Changes
Code Generation Updates (
codegen/lib/layouts/resource.ts):ResourceClassinterface to represent nested class structuresbuildClass()function to recursively generate nested classes with proper indentation and scopingBaseResource,Resources, andSeamResource Base Class (
lib/seam/base_resource.rb):resource_accessorandresource_list_accessorclass methods to register nested resource types[]operator for hash-like subscript access to resource attributesload_from_response()to handle nil values gracefullyResource Classes (Device, AcsEntrance, AcsUser, AccessCode, etc.):
Device::Properties,Device::Location,Device::DeviceManufacturer)attr_accessordeclarations andresource_accessor/resource_list_accessorregistrationsTemplate Updates (
codegen/layouts/resource.hbs,codegen/layouts/partials/resource-class.hbs):Support Modules (
lib/seam/resources/resource_errors_support.rb,resource_warnings_support.rb):update_from_response()override to reset cached converted values when data changesTests:
DeepHashAccessorvalue reuseImplementation Details
The nested class generation respects the resource hierarchy and creates classes at appropriate nesting levels. For example,
Device::Properties::Batteryis generated as a nested class withinDevice::Properties, which is itself nested withinDevice. The system automatically handles:https://claude.ai/code/session_0149sGKk8V2fn6dLvzNor86n