# frozen_string_literal: true
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
module Selenium
module WebDriver
class BiDi
module Serialization
# Immutable value type for the generated protocol classes. +Record.define(spec)+
# bakes each field's wire facts and returns a +::Data+ subclass with serialization.
#
# Cookie = Record.define(name: 'name', value: {wire_key: 'value', ref: 'Network::BytesValue'})
#
# @api private
class Record < ::Data
# Named Field, not Member, to avoid colliding with +::Data#members+.
Field = ::Data.define(:name, :wire_key, :nullable, :ref, :list, :fixed, :enum, :required, :primitive)
def self.define(**spec)
extensible = spec.delete(:extensible) || false
fields = spec.map { |name, meta| field(name, meta) }
names = fields.map(&:name)
names << :extensions if extensible
klass = super(*names)
fields.freeze
# Singleton methods are inherited by `X = Record.define(…)`; ivars would not.
klass.define_singleton_method(:fields) { fields }
klass.define_singleton_method(:extensible?) { extensible }
klass.include(Serializable)
# Capture ::Data's generated +new+, then prepend (not include) Deserializer so
# its +new+ overrides it — outbound +new+ adds validation, while inbound
# +from_json+ builds directly via the captured constructor. Bound to +self+ so a
# subclass builds itself, not the base.
data_new = klass.singleton_class.instance_method(:new)
klass.singleton_class.prepend(Deserializer)
klass.define_singleton_method(:construct) { |**attributes| data_new.bind_call(self, **attributes) }
klass.singleton_class.send(:private, :construct)
klass
end
def self.field(name, meta)
meta = {wire_key: meta} if meta.is_a?(::String)
Field.new(name: name.to_sym, wire_key: meta.fetch(:wire_key, name.to_s),
nullable: meta[:nullable] || false, ref: meta[:ref],
list: meta[:list] || false, fixed: meta.fetch(:fixed, UNSET), enum: meta[:enum],
required: meta.fetch(:required, true), primitive: meta[:primitive])
end
private_class_method :field
# Inbound construction: the keyword +new+ (validated) and the wire +from_json+.
#
# @api private
module Deserializer
def new(**kwargs)
# Start from what was passed so ::Data's constructor rejects an unknown key, then fill
# each field with its value or UNSET (omitted), forcing fixed discriminators.
attributes = kwargs.dup
fields.each { |f| attributes[f.name] = fixed?(f) ? f.fixed : attributes.fetch(f.name, UNSET) }
attributes[:extensions] = kwargs.fetch(:extensions, {}) if extensible?
validate_values(attributes)
construct(**attributes)
end
# Inbound: builds from the wire. A missing required field raises (in +wire_value+),
# enum tokens are mapped back to symbols and an unrecognized one raises (in +read+), and
# extra keys are captured (extensible) or ignored (closed) — strict on shape, lenient on extras.
def from_json(json_payload)
unless json_payload.is_a?(::Hash)
raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}"
end
attributes = fields.to_h do |f|
[f.name, wire_value(f, json_payload)]
end
attributes[:extensions] = extra(json_payload) if extensible?
construct(**attributes)
end
private
# Checks each field's value: a required field cannot be omitted (UNSET), a non-nullable
# field cannot be nil (nil is neither a value nor the UNSET omit-sentinel, so it would be
# silently dropped on the wire), and an enum field must be in its allowed set. The enum
# constant is resolved lazily so a cross-domain enum need not be loaded first. Outbound
# only (from +new+); inbound presence/enum are checked separately in +wire_value+/+read+.
def validate_values(attributes)
fields.each do |f|
value = attributes[f.name]
raise ::ArgumentError, "#{name}##{f.name} is required" if UNSET.equal?(value) && f.required
raise ::ArgumentError, "#{name}##{f.name} cannot be nil" if value.nil? && !f.nullable
next if value.nil? || UNSET.equal?(value)
check_outbound_shape(f, value)
Serialization.validate!("#{name}##{f.name}", value, Protocol.const_get(f.enum)) if f.enum
end
end
# Outbound mirror of check_shape: a list-typed arg must be an array, a scalar-shaped one
# (enum or ref, not a list) must not — a local ArgumentError, not a wire round-trip.
def check_outbound_shape(field, value)
return if field.list == value.is_a?(::Array)
return unless field.list || field.enum || field.ref
kind = field.list ? 'a list' : 'a single value'
raise ::ArgumentError, "#{name}##{field.name} expected #{kind}, got #{value.inspect}"
end
def fixed?(field)
!UNSET.equal?(field.fixed)
end
def wire_value(field, json_payload)
return field.fixed if fixed?(field)
return read(field, json_payload[field.wire_key]) if json_payload.key?(field.wire_key)
return UNSET unless field.required
raise Error::WebDriverError, "#{name}##{field.name} is required but was missing from the response"
end
def read(field, raw)
if raw.nil?
return raw if field.nullable
raise Error::WebDriverError, "#{name}##{field.name} received null but is not nullable"
end
check_shape(field, raw)
return Serialization.to_symbol("#{name}##{field.name}", raw, enum_hash(field)) if field.enum
if field.ref.nil?
check_primitive(field, raw) unless field.list
return raw
end
klass = (@refs ||= {})[field.name] ||= Protocol.const_get(field.ref)
field.list ? read_list(raw, klass) : klass.from_json(raw)
end
# A declared list must arrive as an array; a scalar-shaped field (enum or ref, not a
# list) must not. An opaque field carries no shape descriptor, so it passes through.
def check_shape(field, raw)
return if field.list == raw.is_a?(::Array)
return unless field.list || field.enum || field.ref
raise Error::WebDriverError,
"#{name}##{field.name} expected #{field.list ? 'a list' : 'a single value'}, got #{raw.inspect}"
end
# Ruby classes a checkable primitive admits. `number` is any Numeric (JSON has one
# number type); `integer` requires an Integer — a browser emits `5`, not `5.0`, for an
# integer (JS has no int/float split), so this rarely false-positives yet still rejects
# a genuine non-integer like 1.5. A field with no primitive descriptor is left unchecked.
PRIMITIVE_TYPES = {
'string' => [::String], 'boolean' => [::TrueClass, ::FalseClass],
'number' => [::Numeric], 'integer' => [::Integer]
}.freeze
def check_primitive(field, raw)
expected = PRIMITIVE_TYPES[field.primitive]
return if expected.nil? || expected.any? { |type| raw.is_a?(type) }
raise Error::WebDriverError, "#{name}##{field.name} expected #{field.primitive}, got #{raw.inspect}"
end
def enum_hash(field)
(@enums ||= {})[field.name] ||= Protocol.const_get(field.enum)
end
# Parses each element, recursing into nested lists (e.g. a map's [key, value] pairs)
# so their entries become typed too.
def read_list(raw, klass)
raw.map { |element| element.is_a?(::Array) ? read_list(element, klass) : klass.from_json(element) }
end
def extra(json_payload)
known = (@wire_keys ||= fields.map(&:wire_key))
json_payload.except(*known)
end
end
# @api private
module Serializable
def self.as_json(value)
case value
when Serializable then value.as_json
when ::Array then value.map { |element| as_json(element) }
when ::Hash then value.transform_values { |element| as_json(element) }
else value
end
end
# Omit UNSET fields; emit null only for nullable ones.
def as_json(*)
payload = {}
self.class.fields.each do |f|
value = public_send(f.name)
next if UNSET.equal?(value)
next if value.nil? && !f.nullable
value = Serialization.to_wire(value, Protocol.const_get(f.enum)) if f.enum
payload[f.wire_key] = Serializable.as_json(value)
end
payload.merge!(extensions) if self.class.extensible? && !extensions.empty?
payload
end
end
end
end
end # BiDi
end # WebDriver
end # Selenium