Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/active_model/entity/schemas/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ module Schemas
module JSON
extend ActiveSupport::Concern

included do
class_attribute :read_only_attributes, default: []
end

# Class-level methods.
module ClassMethods
NUMBER_TYPES = %i[big_integer decimal float integer].freeze
STRING_TYPES = %i[string immutable_string date datetime time].freeze
BOOLEAN_TYPES = %i[boolean].freeze

# Intercepts calls to ::attribute method collecting read-only attribute names.
# The option is stripped, since ActiveModel::Type does not know about it.
def attribute(name, *, read_only: false, **)
self.read_only_attributes += [name.to_s] if read_only
super(name, *, **)
end

def json_schema_id
name.gsub("::", ".")
end
Expand Down Expand Up @@ -77,6 +88,12 @@ def make_schema_nullable!(options)
options[:nullable] = true
end

def make_schema_read_only!(options)
options[:allOf] = ["$ref": options.delete(:$ref)] if options[:$ref].present?

options[:readOnly] = true
end

def append_description_if_available!(name, options)
key = name.underscore.to_sym
options[:description] = meta_descriptions[key] if meta_descriptions.key?(key)
Expand All @@ -92,13 +109,15 @@ def as_json_schema(inline: false)
description = meta_descriptions[nil].first
required = required_attributes.map(&:name).map { _1.camelize(:lower) }
nullable = nullable_attributes.map(&:name).index_by { _1.camelize(:lower) }
read_only = read_only_attributes.index_by { _1.camelize(:lower) }

attributes = attribute_types.transform_keys { _1.camelize(:lower) }
properties = attributes.transform_values { json_schema_attribute_for(_1, inline:) }
enums = enum_attributes.transform_keys { _1.camelize(:lower) }

properties.each do |name, options|
make_schema_nullable!(options) if nullable.key?(name)
make_schema_read_only!(options) if read_only.key?(name)
append_description_if_available!(name, options)
append_enum!(enums[name], options, attributes[name]) if enums.key?(name)
end
Expand Down
63 changes: 63 additions & 0 deletions spec/active_model/entity/schemas/json_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ class Person
validates :field_enum_int, inclusion: { in: [1, 3, 7] }
validates :field_enum_string_array, inclusion: { in: %w[an enum] }
end

class ReadOnlyPerson
include ActiveModel::Entity

attribute :field_string, :string
attribute :field_read_only_string, :string, read_only: true
attribute :field_role, :entity, class_name: "SchemasTest::Role"
attribute :field_read_only_role, :entity, class_name: "SchemasTest::Role", read_only: true
attribute :field_roles, :array, of: "SchemasTest::Role"
attribute :field_read_only_roles, :array, of: "SchemasTest::Role", read_only: true
attribute :field_read_only_nullable_role, :entity, class_name: "SchemasTest::Role", read_only: true

validates :field_read_only_nullable_role, presence: { allow_nil: true }
end

class ParentEntity
include ActiveModel::Entity

attribute :field_string, :string
end

class ChildEntity < ParentEntity
attribute :field_read_only_string, :string, read_only: true
end
end

RSpec.describe ActiveModel::Entity::Schemas::JSON do
Expand Down Expand Up @@ -136,6 +160,45 @@ class Person
end
end

describe "read_only: true" do
let(:properties) { SchemasTest::ReadOnlyPerson.as_json_schema[:properties] }

it "leaves attributes without the option untouched" do
expect(properties["fieldString"]).to eq({ type: :string })
expect(properties["fieldRole"]).to eq({ :$ref => "#/components/schemas/SchemasTest.Role" })
expect(properties["fieldRoles"]).to eq({ items: { :$ref => "#/components/schemas/SchemasTest.Role" }, type: :array })
end

it "marks a primitive attribute as read only" do
expect(properties["fieldReadOnlyString"]).to eq({ type: :string, readOnly: true })
end

it "wraps an entity attribute into allOf, since a $ref sibling would be ignored" do
expect(properties["fieldReadOnlyRole"]).to eq({ allOf: [:$ref => "#/components/schemas/SchemasTest.Role"], readOnly: true })
end

it "marks an array attribute itself, not its items, as read only" do
expect(properties["fieldReadOnlyRoles"]).to eq({
items: { :$ref => "#/components/schemas/SchemasTest.Role" },
type: :array,
readOnly: true
})
end

it "combines with nullable without double wrapping" do
expect(properties["fieldReadOnlyNullableRole"]).to eq({
allOf: [:$ref => "#/components/schemas/SchemasTest.Role"],
nullable: true,
readOnly: true
})
end

it "does not leak a subclass declaration into its parent" do
expect(SchemasTest::ChildEntity.as_json_schema[:properties]["fieldReadOnlyString"]).to eq({ type: :string, readOnly: true })
expect(SchemasTest::ParentEntity.read_only_attributes).to be_empty
end
end

describe "validation of arrays of enums" do
let(:entity) { SchemasTest::Person.new(field_boolean: true, field_float: 1.0, field_nullable_string: "x") }

Expand Down