-
-
Notifications
You must be signed in to change notification settings - Fork 57
[Feat] JSON Schema output #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
The content of this commit is created by Claude Code with Claude 4 Sonnet. This commit message is written by human (me).
There was a problem hiding this 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 PR adds a feature to generate JSON Schema output for Alba resources. Key changes include:
- New tests covering various resource schema generation scenarios.
- Rake tasks for generating individual and combined JSON schemas.
- Implementation of a JSON schema generator in the Alba module.
Reviewed Changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/rake_tasks_test.rb | Introduces tests for schema filename generation and task handling. |
| test/json_schema_test.rb | Adds comprehensive tests for different resource types and schema settings. |
| lib/tasks/alba.rake | Defines rake tasks for generating JSON schemas for Alba resources. |
| lib/alba/json_schema.rb | Implements JSON schema generation logic with support for associations, traits, and nested attributes. |
| lib/alba.rb | Loads the JSON schema module. |
| Rakefile | Loads the Alba rake tasks. |
| end | ||
|
|
||
| def transform_key(key) | ||
| return key.to_s unless @resource_class._transform_type != :none |
Copilot
AI
Jun 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The condition 'return key.to_s unless @resource_class._transform_type != :none' uses a double negative that may be confusing. Consider rewriting it as 'return key.to_s if @resource_class._transform_type == :none' for clarity.
| return key.to_s unless @resource_class._transform_type != :none | |
| return key.to_s if @resource_class._transform_type == :none |
|
|
||
| def property_for_nested_attribute(nested_attr) | ||
| # Create a temporary resource class to evaluate the nested block | ||
| temp_class = Alba.resource_class |
Copilot
AI
Jun 20, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] For consistency with trait processing, consider using 'Class.new' to create a temporary class for evaluating the nested attribute block rather than relying on 'Alba.resource_class', which might be ambiguous.
| temp_class = Alba.resource_class | |
| temp_class = Class.new |
Codecov Report❌ Patch coverage is
❌ Your patch check has failed because the patch coverage (77.67%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #443 +/- ##
==========================================
- Coverage 97.53% 92.71% -4.83%
==========================================
Files 14 16 +2
Lines 650 865 +215
Branches 171 231 +60
==========================================
+ Hits 634 802 +168
- Misses 16 63 +47 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds JSON Schema generation for Alba resources via a new Alba::JSONSchema class, integrates it into the library load path, and introduces Rake tasks to generate per-resource and combined schemas. The Rakefile now auto-loads tasks from lib/tasks. Extensive tests cover core, complex, and edge-case behaviors. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Dev as Developer
participant AR as Alba::Resource subclass
participant JS as Alba::JSONSchema
participant Gen as Generator Instance
participant Defs as $defs Registry
Dev->>JS: .generate(AR, **options)
JS->>Gen: new(AR, **options)
JS->>Gen: generate
Gen->>Gen: build base schema ($schema, type: object)
Gen->>AR: read attributes/associations/traits
alt Attributes
Gen->>Gen: map types (string/integer/number/boolean/array)
end
alt Associations
Gen->>Defs: add_resource_definition(target)
Note over Defs: Prevent cycles via processing Set
Gen->>Gen: add $ref to definition
end
alt Nested attributes
Gen->>Gen: evaluate nested block into properties
end
opt Root key wrapping
Gen->>Gen: wrap properties under root key
end
Gen-->>JS: schema hash
JS-->>Dev: JSON Schema
sequenceDiagram
autonumber
actor Dev as Developer
participant R as Rake
participant T as alba:generate_schemas
participant OS as ObjectSpace
participant JS as Alba::JSONSchema
participant FS as File System
Dev->>R: rake alba:generate_schemas
R->>T: invoke
T->>OS: find_alba_resources
loop each resource
T->>JS: generate(resource)
JS-->>T: schema
T->>FS: write schemas/<name>.json
Note over T,FS: Continue on per-resource errors
end
T->>T: generate_combined_schema(resources)
T->>FS: write schemas/combined.json
T-->>R: done
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
Rakefile(1 hunks)lib/alba.rb(1 hunks)lib/alba/json_schema.rb(1 hunks)lib/tasks/alba.rake(1 hunks)test/json_schema_complex_test.rb(1 hunks)test/json_schema_edge_cases_test.rb(1 hunks)test/json_schema_test.rb(1 hunks)test/rake_tasks_test.rb(1 hunks)
🧰 Additional context used
🪛 GitHub Check: codecov/patch
lib/tasks/alba.rake
[warning] 8-8: lib/tasks/alba.rake#L8
Added line #L8 was not covered by tests
[warning] 10-11: lib/tasks/alba.rake#L10-L11
Added lines #L10 - L11 were not covered by tests
[warning] 14-14: lib/tasks/alba.rake#L14
Added line #L14 was not covered by tests
[warning] 18-18: lib/tasks/alba.rake#L18
Added line #L18 was not covered by tests
[warning] 21-21: lib/tasks/alba.rake#L21
Added line #L21 was not covered by tests
[warning] 23-23: lib/tasks/alba.rake#L23
Added line #L23 was not covered by tests
[warning] 25-26: lib/tasks/alba.rake#L25-L26
Added lines #L25 - L26 were not covered by tests
[warning] 28-29: lib/tasks/alba.rake#L28-L29
Added lines #L28 - L29 were not covered by tests
[warning] 31-31: lib/tasks/alba.rake#L31
Added line #L31 was not covered by tests
[warning] 33-33: lib/tasks/alba.rake#L33
Added line #L33 was not covered by tests
[warning] 39-42: lib/tasks/alba.rake#L39-L42
Added lines #L39 - L42 were not covered by tests
[warning] 44-44: lib/tasks/alba.rake#L44
Added line #L44 was not covered by tests
[warning] 47-47: lib/tasks/alba.rake#L47
Added line #L47 was not covered by tests
[warning] 52-52: lib/tasks/alba.rake#L52
Added line #L52 was not covered by tests
[warning] 54-54: lib/tasks/alba.rake#L54
Added line #L54 was not covered by tests
[warning] 58-59: lib/tasks/alba.rake#L58-L59
Added lines #L58 - L59 were not covered by tests
[warning] 63-63: lib/tasks/alba.rake#L63
Added line #L63 was not covered by tests
[warning] 67-67: lib/tasks/alba.rake#L67
Added line #L67 was not covered by tests
[warning] 70-71: lib/tasks/alba.rake#L70-L71
Added lines #L70 - L71 were not covered by tests
[warning] 73-74: lib/tasks/alba.rake#L73-L74
Added lines #L73 - L74 were not covered by tests
[warning] 76-77: lib/tasks/alba.rake#L76-L77
Added lines #L76 - L77 were not covered by tests
[warning] 79-79: lib/tasks/alba.rake#L79
Added line #L79 was not covered by tests
[warning] 81-81: lib/tasks/alba.rake#L81
Added line #L81 was not covered by tests
[warning] 83-83: lib/tasks/alba.rake#L83
Added line #L83 was not covered by tests
[warning] 100-100: lib/tasks/alba.rake#L100
Added line #L100 was not covered by tests
[warning] 103-103: lib/tasks/alba.rake#L103
Added line #L103 was not covered by tests
[warning] 110-110: lib/tasks/alba.rake#L110
Added line #L110 was not covered by tests
[warning] 112-112: lib/tasks/alba.rake#L112
Added line #L112 was not covered by tests
[warning] 114-114: lib/tasks/alba.rake#L114
Added line #L114 was not covered by tests
lib/alba/json_schema.rb
[warning] 156-157: lib/alba/json_schema.rb#L156-L157
Added lines #L156 - L157 were not covered by tests
| type = typed_attr.instance_variable_get(:@type) | ||
| type_name = type&.name | ||
|
|
||
| if type_name == String | ||
| schema['type'] = 'string' | ||
| elsif type_name == Integer | ||
| schema['type'] = 'integer' | ||
| elsif type_name == :Boolean | ||
| schema['type'] = 'boolean' | ||
| elsif type_name == Float | ||
| schema['type'] = 'number' | ||
| elsif type_name.to_s =~ /\AArrayOf(.+)\z/ | ||
| element_type = Regexp.last_match(1).downcase | ||
| schema = { | ||
| 'type' => 'array', | ||
| 'items' => { 'type' => element_type } | ||
| } | ||
| else | ||
| # Custom type or unknown type | ||
| schema['type'] = 'string' | ||
| schema['description'] = "Custom type: #{type_name}" | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix typed attribute handling for symbols.
type&.name blows up when the underlying type is a Symbol (e.g., :Boolean), so schema generation currently raises NoMethodError as soon as we hit :Boolean attributes like published in ArticleResource. Even when it doesn’t raise, the comparisons against String/Integer never match because type&.name returns a String, so we silently fall back to the “Custom type” branch and emit the wrong schema. Please normalize the type up front (fall back to type.to_s when name is unavailable) and branch on the normalized string so primitives such as String, Integer, Float, Boolean, and ArrayOf* map to the correct JSON Schema types.
Apply this diff:
- type = typed_attr.instance_variable_get(:@type)
- type_name = type&.name
-
- if type_name == String
- schema['type'] = 'string'
- elsif type_name == Integer
- schema['type'] = 'integer'
- elsif type_name == :Boolean
- schema['type'] = 'boolean'
- elsif type_name == Float
- schema['type'] = 'number'
- elsif type_name.to_s =~ /\AArrayOf(.+)\z/
- element_type = Regexp.last_match(1).downcase
- schema = {
- 'type' => 'array',
- 'items' => { 'type' => element_type }
- }
- else
- # Custom type or unknown type
- schema['type'] = 'string'
- schema['description'] = "Custom type: #{type_name}"
- end
+ type = typed_attr.instance_variable_get(:@type)
+ type_name =
+ if type.respond_to?(:name) && type.name
+ type.name
+ else
+ type.to_s
+ end
+
+ case type_name
+ when 'String'
+ schema['type'] = 'string'
+ when 'Integer'
+ schema['type'] = 'integer'
+ when 'Float'
+ schema['type'] = 'number'
+ when 'Boolean', ':Boolean', 'TrueClass', 'FalseClass'
+ schema['type'] = 'boolean'
+ when /\AArrayOf(.+)\z/
+ element = Regexp.last_match(1).downcase
+ element = 'number' if element == 'float'
+ schema = {
+ 'type' => 'array',
+ 'items' => { 'type' => element }
+ }
+ else
+ schema['type'] = 'string'
+ schema['description'] = "Custom type: #{type_name}"
+ end📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| type = typed_attr.instance_variable_get(:@type) | |
| type_name = type&.name | |
| if type_name == String | |
| schema['type'] = 'string' | |
| elsif type_name == Integer | |
| schema['type'] = 'integer' | |
| elsif type_name == :Boolean | |
| schema['type'] = 'boolean' | |
| elsif type_name == Float | |
| schema['type'] = 'number' | |
| elsif type_name.to_s =~ /\AArrayOf(.+)\z/ | |
| element_type = Regexp.last_match(1).downcase | |
| schema = { | |
| 'type' => 'array', | |
| 'items' => { 'type' => element_type } | |
| } | |
| else | |
| # Custom type or unknown type | |
| schema['type'] = 'string' | |
| schema['description'] = "Custom type: #{type_name}" | |
| end | |
| type = typed_attr.instance_variable_get(:@type) | |
| type_name = | |
| if type.respond_to?(:name) && type.name | |
| type.name | |
| else | |
| type.to_s | |
| end | |
| case type_name | |
| when 'String' | |
| schema['type'] = 'string' | |
| when 'Integer' | |
| schema['type'] = 'integer' | |
| when 'Float' | |
| schema['type'] = 'number' | |
| when 'Boolean', ':Boolean', 'TrueClass', 'FalseClass' | |
| schema['type'] = 'boolean' | |
| when /\AArrayOf(.+)\z/ | |
| element = Regexp.last_match(1).downcase | |
| element = 'number' if element == 'float' | |
| schema = { | |
| 'type' => 'array', | |
| 'items' => { 'type' => element } | |
| } | |
| else | |
| schema['type'] = 'string' | |
| schema['description'] = "Custom type: #{type_name}" | |
| end |
🤖 Prompt for AI Agents
In lib/alba/json_schema.rb around lines 117 to 138, normalize the detected type
before branching: set type_name to type.name.to_s when available, otherwise to
type.to_s, then use that normalized string for comparisons (e.g. compare against
"String", "Integer", "Float", "Boolean") and detect arrays with a
case-insensitive regex like /\AArrayOf(.+)\z/i so Symbol types (e.g. :Boolean)
and String-returning names are handled consistently and map to the correct JSON
Schema types.
| def generate_definition_name(resource_class) | ||
| return resource_class.to_s if resource_class.is_a?(String) | ||
|
|
||
| name = if resource_class.respond_to?(:name) && resource_class.name | ||
| resource_class.name | ||
| else | ||
| 'AnonymousResource' | ||
| end | ||
|
|
||
| # Remove namespacing and common suffixes | ||
| name = name.split('::').last if name.include?('::') | ||
| name.gsub(/Resource$|Serializer$/, '') | ||
| end | ||
|
|
||
| def add_resource_definition(name, resource_class) | ||
| return if @definitions.key?(name) | ||
| return unless resource_class.respond_to?(:_attributes) | ||
|
|
||
| # Check if we're already processing this resource to prevent infinite recursion | ||
| resource_key = resource_class.object_id | ||
| return if @processing.include?(resource_key) | ||
|
|
||
| # Mark this resource as being processed | ||
| @processing.add(resource_key) | ||
|
|
||
| # Add a placeholder to prevent infinite recursion in self-references | ||
| @definitions[name] = { 'type' => 'object', 'properties' => {} } | ||
|
|
||
| # Generate schema for the referenced resource with shared definitions | ||
| options = @options.reject { |k, _| [:title, :description].include?(k) } | ||
| options[:_definitions] = @definitions | ||
| options[:_processing] = @processing | ||
|
|
||
| definition_generator = self.class.new(resource_class, **options) | ||
| definition_schema = definition_generator.generate | ||
|
|
||
| # Remove the $schema key from definition | ||
| definition_schema.delete('$schema') | ||
|
|
||
| # Merge any nested definitions into our main definitions hash | ||
| if definition_schema['$defs'] | ||
| @definitions.merge!(definition_schema['$defs']) | ||
| definition_schema.delete('$defs') | ||
| end | ||
|
|
||
| # Replace the placeholder with the actual schema | ||
| @definitions[name] = definition_schema | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent $defs collisions for anonymous resources.
generate_definition_name returns the literal "AnonymousResource" whenever the resource class lacks a name, so every inline association (e.g., simple_association vs. collection_items in JSONSchemaEdgeCasesTest) shares the same $defs/AnonymousResource. The second association therefore short-circuits add_resource_definition, and both references point to the first schema, silently dropping fields like value. Please make the fallback name unique—using the class’ object_id is enough—so each anonymous resource gets its own definition.
Apply this diff:
- name = if resource_class.respond_to?(:name) && resource_class.name
- resource_class.name
- else
- 'AnonymousResource'
- end
-
- # Remove namespacing and common suffixes
- name = name.split('::').last if name.include?('::')
- name.gsub(/Resource$|Serializer$/, '')
+ if resource_class.respond_to?(:name) && resource_class.name && !resource_class.name.empty?
+ name = resource_class.name.split('::').last
+ return name.gsub(/Resource$|Serializer$/, '')
+ end
+
+ "AnonymousResource#{resource_class.object_id}"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def generate_definition_name(resource_class) | |
| return resource_class.to_s if resource_class.is_a?(String) | |
| name = if resource_class.respond_to?(:name) && resource_class.name | |
| resource_class.name | |
| else | |
| 'AnonymousResource' | |
| end | |
| # Remove namespacing and common suffixes | |
| name = name.split('::').last if name.include?('::') | |
| name.gsub(/Resource$|Serializer$/, '') | |
| end | |
| def add_resource_definition(name, resource_class) | |
| return if @definitions.key?(name) | |
| return unless resource_class.respond_to?(:_attributes) | |
| # Check if we're already processing this resource to prevent infinite recursion | |
| resource_key = resource_class.object_id | |
| return if @processing.include?(resource_key) | |
| # Mark this resource as being processed | |
| @processing.add(resource_key) | |
| # Add a placeholder to prevent infinite recursion in self-references | |
| @definitions[name] = { 'type' => 'object', 'properties' => {} } | |
| # Generate schema for the referenced resource with shared definitions | |
| options = @options.reject { |k, _| [:title, :description].include?(k) } | |
| options[:_definitions] = @definitions | |
| options[:_processing] = @processing | |
| definition_generator = self.class.new(resource_class, **options) | |
| definition_schema = definition_generator.generate | |
| # Remove the $schema key from definition | |
| definition_schema.delete('$schema') | |
| # Merge any nested definitions into our main definitions hash | |
| if definition_schema['$defs'] | |
| @definitions.merge!(definition_schema['$defs']) | |
| definition_schema.delete('$defs') | |
| end | |
| # Replace the placeholder with the actual schema | |
| @definitions[name] = definition_schema | |
| def generate_definition_name(resource_class) | |
| return resource_class.to_s if resource_class.is_a?(String) | |
| if resource_class.respond_to?(:name) && resource_class.name && !resource_class.name.empty? | |
| name = resource_class.name.split('::').last | |
| return name.gsub(/Resource$|Serializer$/, '') | |
| end | |
| "AnonymousResource#{resource_class.object_id}" | |
| end |
🤖 Prompt for AI Agents
In lib/alba/json_schema.rb around lines 253 to 300, generate_definition_name
currently returns the literal "AnonymousResource" for unnamed classes which
causes $defs name collisions for multiple anonymous resources; change the
fallback to produce a unique name (for example "AnonymousResource_<object_id>"
or include resource_class.object_id) so each anonymous resource gets its own
definition, and ensure any code that relies on the returned name continues to
work (no behavior changes other than uniqueness).
| name = resource_class.name | ||
|
|
||
| # Convert CamelCase to snake_case and remove common suffixes | ||
| name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') | ||
| .gsub(/([a-z\d])([A-Z])/, '\1_\2') | ||
| .downcase | ||
| .gsub(/_resource$|_serializer$/, '') | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle namespaced resource names when building filenames
generate_schema_filename leaves Ruby namespace separators (::) intact (e.g., Admin::UserResource → admin::user). On Windows this results in invalid filenames due to the colon, and even on UNIX it creates awkward names that break combined-schema $defs titles. Replace namespace separators up front (e.g., name = resource_class.name.gsub('::', '_')) before the snake_case conversion so generated files and definition titles are portable.
Apply this diff:
- name = resource_class.name
+ name = resource_class.name.gsub('::', '_')📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| name = resource_class.name | |
| # Convert CamelCase to snake_case and remove common suffixes | |
| name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') | |
| .gsub(/([a-z\d])([A-Z])/, '\1_\2') | |
| .downcase | |
| .gsub(/_resource$|_serializer$/, '') | |
| end | |
| name = resource_class.name.gsub('::', '_') | |
| # Convert CamelCase to snake_case and remove common suffixes | |
| name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') | |
| .gsub(/([a-z\d])([A-Z])/, '\1_\2') | |
| .downcase | |
| .gsub(/_resource$|_serializer$/, '') | |
| end |
🧰 Tools
🪛 GitHub Check: codecov/patch
[warning] 100-100: lib/tasks/alba.rake#L100
Added line #L100 was not covered by tests
[warning] 103-103: lib/tasks/alba.rake#L103
Added line #L103 was not covered by tests
🤖 Prompt for AI Agents
In lib/tasks/alba.rake around lines 100 to 107, generate_schema_filename
currently preserves Ruby namespace separators (::) which yields invalid or
awkward filenames/titles; update the method to replace namespace separators up
front (e.g., set name = resource_class.name.gsub('::', '_')) before performing
the CamelCase→snake_case and suffix removal so generated filenames and $defs
titles are portable across platforms and free of colons.
Ref: #440
Summary by CodeRabbit
New Features
Tests