|
| 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/indexer/event_id" |
| 11 | +require "elastic_graph/indexer/ingestion_adapter" |
| 12 | +require "elastic_graph/indexer/record_preparer" |
| 13 | +require "elastic_graph/support/json_schema/validator_factory" |
| 14 | + |
| 15 | +module ElasticGraph |
| 16 | + class Indexer |
| 17 | + module IngestionAdapter |
| 18 | + # Ingestion adapter for events in ElasticGraph's versioned JSON format: it validates events |
| 19 | + # against the JSON schema identified by the event's `json_schema_version`, and prepares |
| 20 | + # records using that version's view of the schema. |
| 21 | + class JSONEvents |
| 22 | + # @param schema_artifacts [SchemaArtifacts::FromDisk] the schema artifacts |
| 23 | + # @param logger [Logger] the ElasticGraph logger |
| 24 | + # @param configure_record_validator [Proc, nil] optional callback to further configure the record validator |
| 25 | + def initialize(schema_artifacts:, logger:, configure_record_validator: nil) |
| 26 | + @schema_artifacts = schema_artifacts |
| 27 | + @logger = logger |
| 28 | + @configure_record_validator = configure_record_validator |
| 29 | + @record_preparer_factory = RecordPreparer::Factory.new(schema_artifacts) |
| 30 | + end |
| 31 | + |
| 32 | + # (see Interface#handles_event?) |
| 33 | + def handles_event?(event) |
| 34 | + event.key?(JSON_SCHEMA_VERSION_KEY) |
| 35 | + end |
| 36 | + |
| 37 | + # (see Interface#validate_event) |
| 38 | + def validate_event(event) |
| 39 | + selected_json_schema_version = select_json_schema_version(event) { |failure| return failure } |
| 40 | + |
| 41 | + # Because the `select_json_schema_version` picks the closest-matching json schema version, the incoming |
| 42 | + # event might not match the expected json_schema_version value in the json schema (which is a `const` field). |
| 43 | + # This is by design, since we're picking a schema based on best-effort, so to avoid that by-design validation error, |
| 44 | + # performing the envelope validation on a "patched" version of the event. |
| 45 | + event_with_patched_envelope = event.merge({JSON_SCHEMA_VERSION_KEY => selected_json_schema_version}) |
| 46 | + |
| 47 | + if (error_message = validator(EVENT_ENVELOPE_JSON_SCHEMA_NAME, selected_json_schema_version).validate_with_error_message(event_with_patched_envelope)) |
| 48 | + return ValidationResult.invalid(payload_description: "event payload", message: error_message) |
| 49 | + end |
| 50 | + |
| 51 | + record = event.fetch("record") |
| 52 | + graphql_type_name = event.fetch("type") |
| 53 | + |
| 54 | + if (error_message = validator(graphql_type_name, selected_json_schema_version).validate_with_error_message(record)) |
| 55 | + return ValidationResult.invalid(payload_description: "#{graphql_type_name} record", message: error_message) |
| 56 | + end |
| 57 | + |
| 58 | + ValidationResult.valid(@record_preparer_factory.for_json_schema_version(selected_json_schema_version)) |
| 59 | + end |
| 60 | + |
| 61 | + private |
| 62 | + |
| 63 | + def select_json_schema_version(event) |
| 64 | + available_json_schema_versions = @schema_artifacts.available_json_schema_versions |
| 65 | + |
| 66 | + requested_json_schema_version = event[JSON_SCHEMA_VERSION_KEY] |
| 67 | + |
| 68 | + # First check that a valid value has been requested (a positive integer) |
| 69 | + if !event.key?(JSON_SCHEMA_VERSION_KEY) |
| 70 | + yield ValidationResult.invalid(payload_description: JSON_SCHEMA_VERSION_KEY, message: "Event lacks a `#{JSON_SCHEMA_VERSION_KEY}`") |
| 71 | + elsif !requested_json_schema_version.is_a?(Integer) || requested_json_schema_version < 1 |
| 72 | + yield ValidationResult.invalid(payload_description: JSON_SCHEMA_VERSION_KEY, message: "#{JSON_SCHEMA_VERSION_KEY} (#{requested_json_schema_version}) must be a positive integer.") |
| 73 | + end |
| 74 | + |
| 75 | + # The requested version might not necessarily be available (if the publisher is deployed ahead of the indexer, or an old schema |
| 76 | + # version is removed prematurely, or an indexer deployment is rolled back). So the behavior is to always pick the closest-available |
| 77 | + # version. If there's an exact match, great. Even if not an exact match, if the incoming event payload conforms to the closest match, |
| 78 | + # the event can still be indexed. |
| 79 | + # |
| 80 | + # This min_by block will take the closest version in the list. If a tie occurs, the first value in the list wins. The desired |
| 81 | + # behavior is in the event of a tie (highly unlikely, there shouldn't be a gap in available json schema versions), the higher version |
| 82 | + # should be selected. So to get that behavior, the list is sorted in descending order. |
| 83 | + # |
| 84 | + selected_json_schema_version = available_json_schema_versions.sort.reverse.min_by { |version| (requested_json_schema_version - version).abs } |
| 85 | + |
| 86 | + if selected_json_schema_version != requested_json_schema_version |
| 87 | + @logger.info({ |
| 88 | + "message_type" => "ElasticGraphMissingJSONSchemaVersion", |
| 89 | + "message_id" => event["message_id"], |
| 90 | + "event_id" => EventID.from_event(event), |
| 91 | + "event_type" => event["type"], |
| 92 | + "requested_json_schema_version" => requested_json_schema_version, |
| 93 | + "selected_json_schema_version" => selected_json_schema_version |
| 94 | + }) |
| 95 | + end |
| 96 | + |
| 97 | + if selected_json_schema_version.nil? |
| 98 | + yield ValidationResult.invalid( |
| 99 | + payload_description: JSON_SCHEMA_VERSION_KEY, |
| 100 | + message: "Failed to select json schema version. Requested version: #{event[JSON_SCHEMA_VERSION_KEY]}. \ |
| 101 | + Available json schema versions: #{available_json_schema_versions.sort.join(", ")}" |
| 102 | + ) |
| 103 | + end |
| 104 | + |
| 105 | + selected_json_schema_version |
| 106 | + end |
| 107 | + |
| 108 | + def validator(type, selected_json_schema_version) |
| 109 | + factory = validator_factories_by_version[selected_json_schema_version] # : Support::JSONSchema::ValidatorFactory |
| 110 | + factory.validator_for(type) |
| 111 | + end |
| 112 | + |
| 113 | + def validator_factories_by_version |
| 114 | + @validator_factories_by_version ||= ::Hash.new do |hash, json_schema_version| |
| 115 | + factory = Support::JSONSchema::ValidatorFactory.new( |
| 116 | + schema: @schema_artifacts.json_schemas_for(json_schema_version), |
| 117 | + sanitize_pii: true |
| 118 | + ) |
| 119 | + |
| 120 | + if (configure_record_validator = @configure_record_validator) |
| 121 | + factory = configure_record_validator.call(factory) |
| 122 | + end |
| 123 | + |
| 124 | + hash[json_schema_version] = factory |
| 125 | + end |
| 126 | + end |
| 127 | + |
| 128 | + # :nocov: -- this should not be called. Instead, it exists to guard against wrongly raising an error from this class. |
| 129 | + def raise(*args) |
| 130 | + super("`raise` was called on `IngestionAdapter::JSONEvents`, but should not. Instead, use " \ |
| 131 | + "`yield ValidationResult.invalid(...)` so that we can accumulate all invalid events and allow " \ |
| 132 | + "the valid events to still be processed.") |
| 133 | + end |
| 134 | + # :nocov: |
| 135 | + end |
| 136 | + end |
| 137 | + end |
| 138 | +end |
0 commit comments