diff --git a/lib/active_model/entity.rb b/lib/active_model/entity.rb index a9abca8..24e9ccd 100644 --- a/lib/active_model/entity.rb +++ b/lib/active_model/entity.rb @@ -10,6 +10,7 @@ require_relative "entity/parsers/json" require_relative "entity/serializers/json" require_relative "entity/schemas/json" +require_relative "entity/schemas/read_only" require_relative "entity/meta/descriptions" require_relative "entity/equality" require_relative "entity/inspect" @@ -33,6 +34,7 @@ module Entity include ActiveModel::Entity::Parsers::JSON include ActiveModel::Entity::Serializers::JSON include ActiveModel::Entity::Schemas::JSON + include ActiveModel::Entity::Schemas::ReadOnly include ActiveModel::Entity::Meta::Descriptions include ActiveModel::Entity::Equality include ActiveModel::Entity::Inspect diff --git a/lib/active_model/entity/schemas/json.rb b/lib/active_model/entity/schemas/json.rb index 81e1cb0..3b8decd 100644 --- a/lib/active_model/entity/schemas/json.rb +++ b/lib/active_model/entity/schemas/json.rb @@ -12,13 +12,20 @@ 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 + VARIANT_SUFFIXES = { request: "-Request" }.freeze - def json_schema_id - name.gsub("::", ".") + # A hyphen is used deliberately: it cannot appear in a Ruby constant path, so a variant id + # can never collide with the id of a real inner class in the flat components/schemas namespace. + def json_schema_id(variant = nil) + base = name.gsub("::", ".") + return base if variant.nil? + + suffix = VARIANT_SUFFIXES.fetch(variant) + read_only_subtree? ? "#{base}#{suffix}" : base end - def json_schema_ref - "#/components/schemas/#{json_schema_id}" + def json_schema_ref(variant = nil) + "#/components/schemas/#{json_schema_id(variant)}" end def required_attributes @@ -53,20 +60,20 @@ def primitive_type_schema(type) nil end - def entity_schema_for(type, inline) - inline ? type.entity_type.as_json_schema(inline:) : { "$ref": type.entity_type.json_schema_ref } + def entity_schema_for(type, inline, variant) + inline ? type.entity_type.as_json_schema(inline:, variant:) : { "$ref": type.entity_type.json_schema_ref(variant) } end - def array_schema_for(type, inline) - { items: json_schema_attribute_for(type.element_type, inline:), type: :array } + def array_schema_for(type, inline, variant) + { items: json_schema_attribute_for(type.element_type, inline:, variant:), type: :array } end - def json_schema_attribute_for(type, inline: false) + def json_schema_attribute_for(type, inline: false, variant: nil) schema = primitive_type_schema(type) return schema if schema - return entity_schema_for(type, inline) if type.is_a?(Type::Entity) - return array_schema_for(type, inline) if type.is_a?(Type::Array) + return entity_schema_for(type, inline, variant) if type.is_a?(Type::Entity) + return array_schema_for(type, inline, variant) if type.is_a?(Type::Array) raise NotImplementedError end @@ -82,25 +89,46 @@ def append_description_if_available!(name, options) options[:description] = meta_descriptions[key] if meta_descriptions.key?(key) end + # Mirrors ::make_schema_nullable!: an OpenAPI 3.0 sibling of $ref is ignored, so wrap first. + def append_read_only_if_declared!(names, name, options) + return unless names.include?(name) + + options[:allOf] = ["$ref": options.delete(:$ref)] if options[:$ref].present? + options[:readOnly] = true + end + def append_enum!(values, options, type) target = type.is_a?(Type::Array) ? options[:items] : options target[:enum] = values end - def as_json_schema(inline: false) + # Property names a variant omits. Fetches the suffix purely to validate eagerly: otherwise + # a typo'd variant silently strips every read-only property on an entity with no nested + # $ref to route the check through. + def hidden_attribute_names(variant) + return [] if variant.nil? + + VARIANT_SUFFIXES.fetch(variant) + read_only_attributes.map { _1.camelize(:lower) } + end + + def as_json_schema(inline: false, variant: nil) type = :object description = meta_descriptions[nil].first - required = required_attributes.map(&:name).map { _1.camelize(:lower) } + read_only = read_only_attributes.map { _1.camelize(:lower) } + hidden = hidden_attribute_names(variant) + required = required_attributes.map(&:name).map { _1.camelize(:lower) } - hidden nullable = nullable_attributes.map(&:name).index_by { _1.camelize(:lower) } - attributes = attribute_types.transform_keys { _1.camelize(:lower) } - properties = attributes.transform_values { json_schema_attribute_for(_1, inline:) } + attributes = attribute_types.transform_keys { _1.camelize(:lower) }.except(*hidden) + properties = attributes.transform_values { json_schema_attribute_for(_1, inline:, variant:) } enums = enum_attributes.transform_keys { _1.camelize(:lower) } properties.each do |name, options| make_schema_nullable!(options) if nullable.key?(name) append_description_if_available!(name, options) append_enum!(enums[name], options, attributes[name]) if enums.key?(name) + append_read_only_if_declared!(read_only, name, options) end { type:, description:, required:, properties: }.compact diff --git a/lib/active_model/entity/schemas/read_only.rb b/lib/active_model/entity/schemas/read_only.rb new file mode 100644 index 0000000..85b39b7 --- /dev/null +++ b/lib/active_model/entity/schemas/read_only.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ActiveModel + module Entity + module Schemas + # Tracks attributes declared with `read_only: true` and derives the request-variant + # schemas that those attributes imply for this entity and everything it references. + module ReadOnly + extend ActiveSupport::Concern + + included do + class_attribute :read_only_attributes, default: [].freeze, instance_accessor: false + end + + # Class-level methods. + module ClassMethods + # Intercepts ::attribute, consuming `read_only:` before ActiveModel forwards options to Type.lookup. + def attribute(name, *, read_only: false, **) + self.read_only_attributes = (read_only_attributes + [name.to_s]).freeze if read_only + + super(name, *, **) + end + + # Entity classes this one references through attributes that survive into a variant. + # Read-only attributes are excluded: a variant never emits them, so it never refs their target. + def nested_entity_types + attribute_types.except(*read_only_attributes).each_value.filter_map do |type| + type = type.element_type if type.is_a?(Type::Array) + type.entity_type if type.is_a?(Type::Entity) + end + end + + # True when this entity, or anything reachable from it, declares a read-only attribute. + # ponytail: recomputed per call rather than memoized — memoizing an in-progress node as + # false is a real false negative on cycles, and the graph is dozens of tiny classes. + def read_only_subtree?(seen = []) + return false if seen.include?(self) + + seen << self + read_only_attributes.any? || nested_entity_types.any? { _1.read_only_subtree?(seen) } + end + + # Every variant component transitively referenced by this entity, keyed by component id. + def json_schema_variants(variant = :request, acc = {}) + return acc unless read_only_subtree? + + id = json_schema_id(variant) + return acc if acc.key?(id) + + acc[id] = as_json_schema(variant:) + nested_entity_types.each { _1.json_schema_variants(variant, acc) } + acc + end + end + end + end + end +end diff --git a/spec/active_model/entity/schemas/read_only_spec.rb b/spec/active_model/entity/schemas/read_only_spec.rb new file mode 100644 index 0000000..75c2709 --- /dev/null +++ b/spec/active_model/entity/schemas/read_only_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +module ReadOnlyTest + class Address + include ActiveModel::Entity + + attribute :city, :string + end + + class Balance + include ActiveModel::Entity + + attribute :id, :string, read_only: true + attribute :amount, :float + + validates :id, presence: true + validates :amount, presence: true + end + + class Audit + include ActiveModel::Entity + + attribute :who, :string, read_only: true + end + + class Business + include ActiveModel::Entity + + attribute :id, :string, read_only: true + attribute :name, :string + attribute :balance, :entity, class_name: "ReadOnlyTest::Balance" + attribute :balances, :array, of: "ReadOnlyTest::Balance" + attribute :address, :entity, class_name: "ReadOnlyTest::Address" + attribute :audit, :entity, class_name: "ReadOnlyTest::Audit", read_only: true + + validates :id, presence: true + validates :name, presence: true + end + + class Clean + include ActiveModel::Entity + + attribute :addr, :entity, class_name: "ReadOnlyTest::Address" + end + + class LoopA + include ActiveModel::Entity + + attribute :peer, :entity, class_name: "ReadOnlyTest::LoopB" + end + + class LoopB + include ActiveModel::Entity + + attribute :back, :entity, class_name: "ReadOnlyTest::LoopA" + end + + class NodeA + include ActiveModel::Entity + + attribute :secret, :string, read_only: true + attribute :peer, :entity, class_name: "ReadOnlyTest::NodeB" + end + + class NodeB + include ActiveModel::Entity + + attribute :back, :entity, class_name: "ReadOnlyTest::NodeA" + end + + # Multi-word names: the pruning in ::nested_entity_types keys off raw snake_case attribute names + # while ::as_json_schema hides camelCase ones. Single-word fixtures cannot tell the two apart. + class AuditLog + include ActiveModel::Entity + + attribute :entry, :string, read_only: true + end + + class Vendor + include ActiveModel::Entity + + attribute :phone_numbers, :array, of: :string, read_only: true + attribute :audit_log, :entity, class_name: "ReadOnlyTest::AuditLog", read_only: true + attribute :display_name, :string + end + + # A cycle where read-only-ness is reachable ONLY through the back edge, so ::read_only_subtree? + # cannot short-circuit on its own attributes. Guards against memoizing an in-progress node as false. + class RingHub + include ActiveModel::Entity + + attribute :spoke, :entity, class_name: "ReadOnlyTest::RingSpoke" + attribute :vault, :entity, class_name: "ReadOnlyTest::RingVault" + end + + class RingSpoke + include ActiveModel::Entity + + attribute :hub, :entity, class_name: "ReadOnlyTest::RingHub" + end + + class RingVault + include ActiveModel::Entity + + attribute :token, :string, read_only: true + end + + class Shapes + include ActiveModel::Entity + + attribute :a, :string, read_only: true + attribute :b, :integer, default: 7, read_only: true + attribute :c, :boolean, read_only: true + attribute :d, :entity, class_name: "ReadOnlyTest::Address", read_only: true + attribute :e, :array, of: "ReadOnlyTest::Address", read_only: true + attribute :f, read_only: true + attribute :g, :string + end + + class Described + include ActiveModel::Entity + + desc "the described entity" + desc "the identifier" + attribute :id, :string, read_only: true + desc "the label" + attribute :label, :string + end + + class Parent + include ActiveModel::Entity + + attribute :pid, :string, read_only: true + end + + class Sub < Parent + attribute :extra, :string, read_only: true + end + + class Sub2 < Parent + end +end + +RSpec.describe ActiveModel::Entity::Schemas::ReadOnly do + it "works" do + schema = ReadOnlyTest::Balance.as_json_schema + + expect(schema).to eq({ + type: :object, + required: %w[id amount], + properties: { + "id" => { type: :string, readOnly: true }, + "amount" => { type: :number } + } + }) + end + + describe "the read_only: option on ::attribute" do + it "is accepted on every declaration shape" do + expect(ReadOnlyTest::Shapes.read_only_attributes).to eq(%w[a b c d e f]) + end + + it "does not swallow other attribute options" do + expect(ReadOnlyTest::Shapes.new.b).to eq(7) + end + end + + describe "the request variant of a flat entity" do + it "refs the stripped component" do + expect(ReadOnlyTest::Balance.json_schema_ref(:request)).to eq("#/components/schemas/ReadOnlyTest.Balance-Request") + end + + it "drops read-only attributes from both properties and required" do + expect(ReadOnlyTest::Balance.as_json_schema(variant: :request)).to eq({ + type: :object, + required: %w[amount], + properties: { "amount" => { type: :number } } + }) + end + end + + describe "the request variant of a nested entity" do + it "strips transitively and refs variants from variants" do + expect(ReadOnlyTest::Business.json_schema_variants).to eq({ + "ReadOnlyTest.Business-Request" => { + type: :object, + required: %w[name], + properties: { + "name" => { type: :string }, + "balance" => { :$ref => "#/components/schemas/ReadOnlyTest.Balance-Request" }, + "balances" => { items: { :$ref => "#/components/schemas/ReadOnlyTest.Balance-Request" }, type: :array }, + "address" => { :$ref => "#/components/schemas/ReadOnlyTest.Address" } + } + }, + "ReadOnlyTest.Balance-Request" => { + type: :object, + required: %w[amount], + properties: { "amount" => { type: :number } } + } + }) + end + + it "only emits variants that something refs" do + expect(ReadOnlyTest::Audit.read_only_subtree?).to be(true) + expect(ReadOnlyTest::Business.json_schema_variants.keys).to eq( + ["ReadOnlyTest.Business-Request", "ReadOnlyTest.Balance-Request"] + ) + end + + it "composes with inline: true" do + schema = ReadOnlyTest::Business.as_json_schema(inline: true, variant: :request) + + expect(schema[:properties]["balance"]).to eq({ + type: :object, + required: %w[amount], + properties: { "amount" => { type: :number } } + }) + end + end + + describe "a nested entity whose children have no read-only attributes" do + it "emits no variant" do + expect(ReadOnlyTest::Clean.json_schema_variants).to eq({}) + end + + it "resolves the request ref to the plain component" do + expect(ReadOnlyTest::Clean.json_schema_ref(:request)).to eq("#/components/schemas/ReadOnlyTest.Clean") + expect(ReadOnlyTest::Address.json_schema_ref(:request)).to eq("#/components/schemas/ReadOnlyTest.Address") + end + end + + describe "a cyclic entity graph" do + it "terminates and elides when nothing is read-only" do + expect(ReadOnlyTest::LoopA.read_only_subtree?).to be(false) + expect(ReadOnlyTest::LoopA.json_schema_variants).to eq({}) + expect(ReadOnlyTest::LoopA.json_schema_ref(:request)).to eq("#/components/schemas/ReadOnlyTest.LoopA") + end + + it "sees read-only-ness reached only through the cycle" do + expect(ReadOnlyTest::NodeB.read_only_subtree?).to be(true) + end + + it "emits each component once from either entry point" do + expect(ReadOnlyTest::NodeA.json_schema_variants.keys).to eq( + ["ReadOnlyTest.NodeA-Request", "ReadOnlyTest.NodeB-Request"] + ) + expect(ReadOnlyTest::NodeB.json_schema_variants.keys).to eq( + ["ReadOnlyTest.NodeB-Request", "ReadOnlyTest.NodeA-Request"] + ) + end + end + + describe "readOnly on the full schema" do + it "wraps a $ref property in allOf" do + expect(ReadOnlyTest::Business.as_json_schema[:properties]["audit"]).to eq({ + allOf: [{ :$ref => "#/components/schemas/ReadOnlyTest.Audit" }], + readOnly: true + }) + end + end + + describe "BE-47 leaking read-only information to subclasses" do + it "keeps every class's list to itself" do + expect(ReadOnlyTest::Parent.read_only_attributes).to eq(%w[pid]) + expect(ReadOnlyTest::Sub.read_only_attributes).to eq(%w[pid extra]) + expect(ReadOnlyTest::Sub2.read_only_attributes).to eq(%w[pid]) + end + end + + describe "the ::attribute super chain" do + it "keeps desc annotations aligned with their attributes" do + expect(ReadOnlyTest::Described.meta_descriptions).to eq({ + nil => ["the described entity"], + id: "the identifier", + label: "the label" + }) + end + + it "still emits descriptions alongside readOnly" do + expect(ReadOnlyTest::Described.as_json_schema[:properties]["id"]).to eq({ + type: :string, + description: "the identifier", + readOnly: true + }) + end + end + + describe "an unknown variant" do + it "raises rather than silently returning the base ref" do + expect { ReadOnlyTest::Business.json_schema_ref(:requst) }.to raise_error(KeyError) + end + + it "raises on a flat entity too, which has no nested $ref to route the check through" do + expect { ReadOnlyTest::Balance.as_json_schema(variant: :requst) }.to raise_error(KeyError) + end + end + + describe "multi-word attribute names" do + it "strips them from the variant despite the snake_case/camelCase split" do + expect(ReadOnlyTest::Vendor.as_json_schema(variant: :request)[:properties].keys).to eq(["displayName"]) + end + + it "emits no variant for an entity reachable only through a read-only edge" do + expect(ReadOnlyTest::Vendor.json_schema_variants.keys).to eq(["ReadOnlyTest.Vendor-Request"]) + end + end + + describe "a cycle whose read-only-ness is only reachable through the back edge" do + it "reports the subtree dirty from every entry point" do + expect(ReadOnlyTest::RingHub.read_only_subtree?).to be(true) + expect(ReadOnlyTest::RingSpoke.read_only_subtree?).to be(true) + end + + it "emits a variant for the node that has no read-only attribute of its own" do + expect(ReadOnlyTest::RingHub.json_schema_variants.keys).to contain_exactly( + "ReadOnlyTest.RingHub-Request", + "ReadOnlyTest.RingSpoke-Request", + "ReadOnlyTest.RingVault-Request" + ) + end + end + + describe "runtime behaviour" do + it "is unaffected: read-only is schema-only metadata" do + expect(ReadOnlyTest::Business.from_json({ "id" => "x" }).id).to eq("x") + end + end +end