Skip to content

Commit 700dc21

Browse files
committed
Wire JSON ingestion schema extension modules
1 parent 4d549ec commit 700dc21

39 files changed

Lines changed: 1459 additions & 38 deletions

File tree

config/site/support/doctest_helper.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# frozen_string_literal: true
88

99
require "elastic_graph/apollo/schema_definition/api_extension"
10+
require "elastic_graph/json_ingestion/schema_definition/api_extension"
1011
require "elastic_graph/schema_artifacts/runtime_metadata/schema_element_names"
1112
require "elastic_graph/schema_definition/api"
1213
require "elastic_graph/schema_definition/schema_artifact_manager"
@@ -51,6 +52,7 @@ module ElasticGraph
5152
descriptions_needing_schema_def_api_and_extension_modules = {
5253
"ElasticGraph.define_schema" => [],
5354
"ElasticGraph::Apollo::SchemaDefinition" => [ElasticGraph::Apollo::SchemaDefinition::APIExtension],
55+
"ElasticGraph::JSONIngestion::SchemaDefinition" => [ElasticGraph::JSONIngestion::SchemaDefinition::APIExtension],
5456
"ElasticGraph::SchemaDefinition" => [],
5557
"ElasticGraph::Warehouse::SchemaDefinition" => [ElasticGraph::Warehouse::SchemaDefinition::APIExtension]
5658
}
@@ -90,11 +92,16 @@ module ElasticGraph
9092
end
9193
end
9294

93-
doctest.before "ElasticGraph::SchemaDefinition::API#json_schema_version" do
94-
ElasticGraph.define_schema do |schema|
95-
# `schema.json_schema_version` raises an error when the version is set more than once.
96-
# By default we set it above. Here we clear it to allow our example to set it.
97-
schema.state.json_schema_version = nil
95+
[
96+
"ElasticGraph::JSONIngestion::SchemaDefinition::APIExtension#json_schema_version",
97+
"ElasticGraph::SchemaDefinition::API#json_schema_version"
98+
].each do |description|
99+
doctest.before description do
100+
ElasticGraph.define_schema do |schema|
101+
# `schema.json_schema_version` raises an error when the version is set more than once.
102+
# By default we set it above. Here we clear it to allow our example to set it.
103+
schema.state.json_schema_version = nil
104+
end
98105
end
99106
end
100107

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
require "elastic_graph/constants"
10+
require "elastic_graph/json_ingestion/schema_definition/factory_extension"
11+
require "elastic_graph/json_ingestion/schema_definition/state_extension"
12+
13+
module ElasticGraph
14+
module JSONIngestion
15+
# Namespace for all JSON Schema schema definition support.
16+
#
17+
# {SchemaDefinition::APIExtension} is the primary entry point and should be used as a schema definition extension module.
18+
module SchemaDefinition
19+
# Module designed to be extended onto an {ElasticGraph::SchemaDefinition::API} instance
20+
# to add JSON Schema ingestion serializer capabilities.
21+
module APIExtension
22+
# Wires up the JSON ingestion extensions when this module is extended onto an API instance.
23+
#
24+
# @param api [ElasticGraph::SchemaDefinition::API] the API instance to extend
25+
# @return [void]
26+
# @api private
27+
def self.extended(api)
28+
api.state.extend(StateExtension)
29+
api.factory.extend(FactoryExtension)
30+
31+
api.on_built_in_types do |type|
32+
if type.name == api.state.type_ref("GeoLocation").to_final_form.name
33+
# @type var geo_location_type: ElasticGraph::SchemaDefinition::SchemaElements::TypeWithSubfields & SchemaElements::TypeWithSubfieldsExtension
34+
geo_location_type = _ = type
35+
names = api.state.schema_elements
36+
37+
# We use `nullable: false` because `GeoLocation` is indexed as a single `geo_point` field,
38+
# and therefore can't support a `latitude` without a `longitude` or vice-versa.
39+
latitude = geo_location_type.graphql_fields_by_name.fetch(names.latitude) # : ElasticGraph::SchemaDefinition::SchemaElements::Field & SchemaElements::FieldExtension
40+
longitude = geo_location_type.graphql_fields_by_name.fetch(names.longitude) # : ElasticGraph::SchemaDefinition::SchemaElements::Field & SchemaElements::FieldExtension
41+
latitude.json_schema minimum: -90, maximum: 90, nullable: false
42+
longitude.json_schema minimum: -180, maximum: 180, nullable: false
43+
end
44+
end
45+
end
46+
47+
# Defines the version number of the current JSON schema. Importantly, every time a change is made that impacts the JSON schema
48+
# artifact, the version number must be incremented to ensure that each different version of the JSON schema is identified by a unique
49+
# version number. The publisher will then include this version number in published events to identify the version of the schema it
50+
# was using. This avoids the need to deploy the publisher and ElasticGraph indexer at the same time to keep them in sync.
51+
#
52+
# @note While this is an important part of how ElasticGraph is designed to support schema evolution, it can be annoying constantly
53+
# have to increment this while rapidly changing the schema during prototyping. You can disable the requirement to increment this
54+
# on every JSON schema change with {#enforce_json_schema_version}.
55+
#
56+
# @param version [Integer] current version number of the JSON schema artifact
57+
# @return [void]
58+
# @see #enforce_json_schema_version
59+
#
60+
# @example Set the JSON schema version to 1
61+
# ElasticGraph.define_schema do |schema|
62+
# schema.json_schema_version 1
63+
# end
64+
def json_schema_version(version)
65+
state = json_ingestion_state
66+
67+
if !version.is_a?(Integer) || version < 1
68+
raise Errors::SchemaError, "`json_schema_version` must be a positive integer. Specified version: #{version}"
69+
end
70+
71+
if state.json_schema_version
72+
raise Errors::SchemaError, "`json_schema_version` can only be set once on a schema. Previously-set version: #{state.json_schema_version}"
73+
end
74+
75+
state.json_schema_version = version
76+
state.json_schema_version_setter_location = caller_locations(1, 1).to_a.first
77+
nil
78+
end
79+
80+
# Configures whether JSON schema artifact dumping enforces the requirement that the JSON schema version is incremented every time
81+
# dumping the JSON schemas results in a changed artifact. Defaults to `true`.
82+
#
83+
# @note Generally speaking, you will want this to be `true` for any ElasticGraph application that is in
84+
# production as the versioning of JSON schemas is what supports safe schema evolution as it allows
85+
# ElasticGraph to identify which version of the JSON schema the publishing system was operating on
86+
# when it published an event.
87+
#
88+
# It can be useful to set it to `false` before your application is in production, as you do not want
89+
# to be forced to bump the version after every single schema change while you are building an initial
90+
# prototype.
91+
#
92+
# @param value [Boolean] whether to require `json_schema_version` to be incremented on changes that impact `json_schemas.yaml`
93+
# @return [void]
94+
# @see #json_schema_version
95+
#
96+
# @example Disable enforcement during initial prototyping
97+
# ElasticGraph.define_schema do |schema|
98+
# # TODO: remove this once we're past the prototyping stage
99+
# schema.enforce_json_schema_version false
100+
# end
101+
def enforce_json_schema_version(value)
102+
unless value == true || value == false
103+
raise Errors::SchemaError, "`enforce_json_schema_version` must be a boolean. Specified value: #{value.inspect}"
104+
end
105+
106+
json_ingestion_state.enforce_json_schema_version = value
107+
nil
108+
end
109+
110+
# Defines strictness of the JSON schema validation. By default, the JSON schema will require all fields to be provided by the
111+
# publisher (but they can be nullable) and will ignore extra fields that are not defined in the schema. Use this method to
112+
# configure this behavior.
113+
#
114+
# @param allow_omitted_fields [bool] Whether nullable fields can be omitted from indexing events.
115+
# @param allow_extra_fields [bool] Whether extra fields (e.g. beyond fields defined in the schema) can be included in indexing events.
116+
# @return [void]
117+
#
118+
# @note If you allow both omitted fields and extra fields, ElasticGraph's JSON schema validation will allow (and ignore) misspelled
119+
# field names in indexing events. For example, if the ElasticGraph schema has a nullable field named `parentId` but the publisher
120+
# accidentally provides it as `parent_id`, ElasticGraph would happily ignore the `parent_id` field entirely, because `parentId`
121+
# is allowed to be omitted and `parent_id` would be treated as an extra field. Therefore, we recommend that you only set one of
122+
# these to `true` (or none).
123+
#
124+
# @example Allow omitted fields and disallow extra fields
125+
# ElasticGraph.define_schema do |schema|
126+
# schema.json_schema_strictness allow_omitted_fields: true, allow_extra_fields: false
127+
# end
128+
def json_schema_strictness(allow_omitted_fields: false, allow_extra_fields: true)
129+
state = json_ingestion_state
130+
131+
unless [true, false].include?(allow_omitted_fields)
132+
raise Errors::SchemaError, "`allow_omitted_fields` must be true or false"
133+
end
134+
135+
unless [true, false].include?(allow_extra_fields)
136+
raise Errors::SchemaError, "`allow_extra_fields` must be true or false"
137+
end
138+
139+
state.allow_omitted_json_schema_fields = allow_omitted_fields
140+
state.allow_extra_json_schema_fields = allow_extra_fields
141+
nil
142+
end
143+
144+
private
145+
146+
# Returns the API's `state` narrowed to include this gem's `StateExtension`. Centralizes
147+
# the Steep cast that's needed because Steep can't see the `extend(StateExtension)` applied
148+
# at runtime in `extended`.
149+
def json_ingestion_state
150+
state # : ElasticGraph::SchemaDefinition::State & StateExtension
151+
end
152+
end
153+
end
154+
end
155+
end
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
require "elastic_graph/constants"
10+
require "elastic_graph/json_ingestion/schema_definition/indexing/field_type/enum"
11+
require "elastic_graph/json_ingestion/schema_definition/indexing/field_type/object"
12+
require "elastic_graph/json_ingestion/schema_definition/indexing/field_type/scalar"
13+
require "elastic_graph/json_ingestion/schema_definition/indexing/field_type/union"
14+
require "elastic_graph/json_ingestion/schema_definition/indexing/index_extension"
15+
require "elastic_graph/graphql/scalar_coercion_adapters/valid_time_zones"
16+
require "elastic_graph/json_ingestion/schema_definition/results_extension"
17+
require "elastic_graph/json_ingestion/schema_definition/schema_artifact_manager_extension"
18+
require "elastic_graph/json_ingestion/schema_definition/schema_elements/enum_type_extension"
19+
require "elastic_graph/json_ingestion/schema_definition/schema_elements/field_extension"
20+
require "elastic_graph/json_ingestion/schema_definition/schema_elements/scalar_type_extension"
21+
require "elastic_graph/json_ingestion/schema_definition/schema_elements/type_with_subfields_extension"
22+
23+
module ElasticGraph
24+
module JSONIngestion
25+
module SchemaDefinition
26+
# Extension module applied to `ElasticGraph::SchemaDefinition::Factory` to wire up
27+
# JSON Schema support on Results and SchemaArtifactManager instances.
28+
#
29+
# @api private
30+
module FactoryExtension
31+
# Default JSON schema options applied to ElasticGraph's built-in scalar types as they
32+
# are constructed. Keyed by the un-overridden type name, because built-in type
33+
# registration always uses the canonical type name before `type_name_overrides` are
34+
# applied to the resulting type reference.
35+
BUILT_IN_SCALAR_JSON_SCHEMA_OPTIONS_BY_NAME = {
36+
"Boolean" => {type: "boolean"},
37+
"Float" => {type: "number"},
38+
"ID" => {type: "string"},
39+
"Int" => {type: "integer", minimum: INT_MIN, maximum: INT_MAX},
40+
"String" => {type: "string"},
41+
"Cursor" => {type: "string"},
42+
"Date" => {type: "string", format: "date"},
43+
"DateTime" => {type: "string", format: "date-time"},
44+
"LocalTime" => {type: "string", pattern: VALID_LOCAL_TIME_JSON_SCHEMA_PATTERN},
45+
"TimeZone" => {type: "string", enum: GraphQL::ScalarCoercionAdapters::VALID_TIME_ZONES.to_a.freeze},
46+
"Untyped" => {type: ["array", "boolean", "integer", "number", "object", "string"].freeze},
47+
"JsonSafeLong" => {type: "integer", minimum: JSON_SAFE_LONG_MIN, maximum: JSON_SAFE_LONG_MAX},
48+
"LongString" => {type: "integer", minimum: LONG_STRING_MIN, maximum: LONG_STRING_MAX}
49+
}.freeze
50+
51+
# @private
52+
def new_enum_type(name)
53+
super(name) do |type|
54+
extended_type = type.extend(SchemaElements::EnumTypeExtension) # : ::ElasticGraph::SchemaDefinition::SchemaElements::EnumType & SchemaElements::EnumTypeExtension
55+
yield extended_type if block_given?
56+
end
57+
end
58+
59+
# @private
60+
def new_enum_indexing_field_type(enum_value_names)
61+
Indexing::FieldType::Enum.new(super)
62+
end
63+
64+
# @private
65+
def new_field(**kwargs)
66+
super(**kwargs) do |field|
67+
extended_field = field.extend(SchemaElements::FieldExtension) # : ::ElasticGraph::SchemaDefinition::SchemaElements::Field & SchemaElements::FieldExtension
68+
yield extended_field if block_given?
69+
end
70+
end
71+
72+
# @private
73+
def new_index(name, settings, type)
74+
super(name, settings, type) do |index|
75+
extended_index = index.extend(Indexing::IndexExtension) # : ::ElasticGraph::SchemaDefinition::Indexing::Index & Indexing::IndexExtension
76+
yield extended_index if block_given?
77+
end
78+
end
79+
80+
# @private
81+
def new_object_indexing_field_type(...)
82+
Indexing::FieldType::Object.new(super)
83+
end
84+
85+
# @private
86+
def new_scalar_type(name)
87+
super(name) do |type|
88+
extended_type = type.extend(SchemaElements::ScalarTypeExtension) # : ::ElasticGraph::SchemaDefinition::SchemaElements::ScalarType & SchemaElements::ScalarTypeExtension
89+
if state.initially_registered_built_in_types.empty? && (options = BUILT_IN_SCALAR_JSON_SCHEMA_OPTIONS_BY_NAME[name.to_s])
90+
extended_type.json_schema(**options)
91+
end
92+
93+
yield extended_type if block_given?
94+
extended_type.finalize_json_schema_configuration!
95+
end
96+
end
97+
98+
# @private
99+
def new_scalar_indexing_field_type(scalar_type:)
100+
Indexing::FieldType::Scalar.new(super)
101+
end
102+
103+
# @private
104+
def new_type_with_subfields(schema_kind, name, wrapping_type:, field_factory:)
105+
super(schema_kind, name, wrapping_type: wrapping_type, field_factory: field_factory) do |type|
106+
extended_type = type.extend(SchemaElements::TypeWithSubfieldsExtension) # : ::ElasticGraph::SchemaDefinition::SchemaElements::TypeWithSubfields & SchemaElements::TypeWithSubfieldsExtension
107+
yield extended_type if block_given?
108+
end
109+
end
110+
111+
# @private
112+
def new_union_indexing_field_type(subtypes_by_name)
113+
Indexing::FieldType::Union.new(super)
114+
end
115+
116+
# Creates a new Results instance with JSON Schema extensions.
117+
#
118+
# @return [ElasticGraph::SchemaDefinition::Results] the created results instance
119+
def new_results
120+
super.tap do |results|
121+
results.extend(ResultsExtension)
122+
end
123+
end
124+
125+
# Creates a new SchemaArtifactManager instance with JSON Schema extensions.
126+
#
127+
# @return [ElasticGraph::SchemaDefinition::SchemaArtifactManager] the created artifact manager
128+
def new_schema_artifact_manager(...)
129+
super.tap do |manager|
130+
manager.extend(SchemaArtifactManagerExtension)
131+
end
132+
end
133+
end
134+
end
135+
end
136+
end

0 commit comments

Comments
 (0)