Skip to content
Closed
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
2 changes: 2 additions & 0 deletions lib/active_model/entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
58 changes: 43 additions & 15 deletions lib/active_model/entity/schemas/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions lib/active_model/entity/schemas/read_only.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading