From a2422bd527de5e4b1dc44799e77042e8f66f26af Mon Sep 17 00:00:00 2001 From: HolyWalley Date: Thu, 6 Aug 2026 20:40:16 +0200 Subject: [PATCH] [BAC-1482] Add read_only attribute support Adds a `read_only: true` option on `attribute`, emitted as `readOnly: true` on that property in the generated JSON schema: attribute :balance, :entity, class_name: "Entities::Businesses::Balance", read_only: true The option is stripped before `super`, since ActiveModel forwards `**options` into `Type.lookup` and every symbolic type raises ArgumentError on an unknown keyword. `readOnly` on a `$ref` property is wrapped in `allOf`, mirroring the existing `make_schema_nullable!` -- an OpenAPI 3.0 sibling of `$ref` is ignored, which is exactly the motivating case. The two are idempotent together. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WnpMsUXxckwovSfTfX37KK --- lib/active_model/entity/schemas/json.rb | 19 ++++++ spec/active_model/entity/schemas/json_spec.rb | 63 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/lib/active_model/entity/schemas/json.rb b/lib/active_model/entity/schemas/json.rb index 81e1cb0..2864852 100644 --- a/lib/active_model/entity/schemas/json.rb +++ b/lib/active_model/entity/schemas/json.rb @@ -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 @@ -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) @@ -92,6 +109,7 @@ 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:) } @@ -99,6 +117,7 @@ def as_json_schema(inline: false) 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 diff --git a/spec/active_model/entity/schemas/json_spec.rb b/spec/active_model/entity/schemas/json_spec.rb index 1a00c67..ae5e9e7 100644 --- a/spec/active_model/entity/schemas/json_spec.rb +++ b/spec/active_model/entity/schemas/json_spec.rb @@ -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 @@ -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") }