Skip to content

Implement Typed Documents and TypeRegistry #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 38 commits into
base: decaf
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d33fcdf
Add type registry prototype class
jterapin Mar 6, 2025
5ff40cc
Add type registry to codegenerated schema
jterapin Mar 6, 2025
66a6285
Update projections
jterapin Mar 6, 2025
c5e45ed
Merge branch 'decaf' into typed_documents
jterapin Mar 7, 2025
2bd15a2
Merge branch 'decaf' into typed_documents
jterapin Mar 10, 2025
03bfa82
Merge branch 'decaf' into typed_documents
jterapin Mar 12, 2025
e6435d5
Update requires
jterapin Apr 3, 2025
0830827
Add initial document implementation
jterapin Apr 3, 2025
877654f
Merge decaf into branch
jterapin Apr 3, 2025
a61318f
Update to include cbor
jterapin Apr 7, 2025
4edfae3
Expand on typed docs
jterapin Apr 7, 2025
ff959f1
Update file names
jterapin Apr 7, 2025
8b9b560
Merge branch 'decaf' into typed_documents
jterapin Apr 9, 2025
598db66
More refactoring
jterapin Apr 11, 2025
3a4c0d1
Merge branch 'decaf' into typed_documents
jterapin Apr 11, 2025
269b2b5
Remove scratches
jterapin Apr 11, 2025
90c58ce
Fix rubocop
jterapin Apr 11, 2025
a1e46cc
Clean up document
jterapin Apr 14, 2025
8b666cd
Clean document specs
jterapin Apr 14, 2025
2ddf4bd
Update TypeRegistry
jterapin Apr 14, 2025
112ddf4
Add documentation
jterapin Apr 14, 2025
6283813
Add TypeRegistry specs
jterapin Apr 14, 2025
efbfa5e
Merge branch 'decaf' into typed_documents
jterapin Apr 14, 2025
88ff845
Add TypeRegistry tests
jterapin Apr 15, 2025
66b2cde
Update projections
jterapin Apr 15, 2025
22998a0
Update syntax
jterapin Apr 15, 2025
9afeacd
Update projections
jterapin Apr 15, 2025
232844c
Merge branch 'decaf' into typed_documents
jterapin Apr 17, 2025
e8920fd
Change schema name to shapes to stay aligned
jterapin Apr 17, 2025
ef8c027
Remove as_typed method from TypeRegistry
jterapin Apr 17, 2025
3854661
Create TimeHelper module
jterapin Apr 18, 2025
48e1b0f
Fix timestamp failures
jterapin Apr 18, 2025
04d0d5b
Refactor type registry per feedbacks
jterapin Apr 21, 2025
b35e09b
Update 1 projection
jterapin Apr 21, 2025
66825be
Update weather projection
jterapin Apr 21, 2025
4efe669
Use SchemaHelper for testing
jterapin Apr 21, 2025
73e0e83
Update TypeRegistry to use SchemaHelper for testing
jterapin Apr 21, 2025
60ef29c
Add TypeRegistry documentation
jterapin Apr 21, 2025
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ bundle exec smithy-ruby smith client --gem-name weather --gem-version 1.0.0 --de
### IRB
IRB on `weather` gem:
```
irb -I projections/weather/lib -I gems/smithy-client/lib -I gems/smithy-schema/lib -r weather
irb -I projections/weather/lib -I gems/smithy-client/lib -I gems/smithy-schema/lib -I gems/smithy-cbor/lib -r weather
```

Create a Weather client:
Expand Down
10 changes: 10 additions & 0 deletions gems/smithy-client/lib/smithy-client/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ def service=(service)
define_operation_methods
end

# @return [Schema::TypeRegistry]
def type_registry
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the intention of placing this on base and why is it needed here? This will add a method to clients. I think we should bias to make this feature for schema gems only for now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all gems should have the type register available - though I'm not sure it belongs as a method on client class - maybe as a method on Schema module, eg: Schema.type_registery

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need access to TypeRegistry during SERDE process - when it comes to deserializing typed documents. I haven't quite figured out where would be a good "home" for this.

I'm thinking of few options...
1 - Update Smithy::Client::HandlerContext to have access to the type registry (when deserializing typed documents - we can find the associated shape) - like: context.type_registry
2 - Keep the reference on the client so we don't need to add another attribute on the HandlerContext - we could do: context.client.type_registry to access during SERDE process
3 - Should ServiceShape have an attribute called type_registry so we could do: client.service.type_registry for easily access?
3 - Just omit the reference for now until we need it. If you need it for whatever reason, you gotta use this constant: BirdService::Schema.TYPE_REGISTRY

If we decide to omit this now, when do we need it?
Any JSON-esque protocol since they support JSON docs

What do y'all think?

@type_registry ||= Schema::TypeRegistry.new
end

# @param [TypeRegistry] registry
def set_type_registry=(registry)
@type_registry = registry
end

# @option options [ServiceShape] :service (ServiceShape.new)
# @option options [Array<Plugin>] :plugins ([]) A list of plugins to
# add to the client class created.
Expand Down
14 changes: 14 additions & 0 deletions gems/smithy-client/spec/smithy-client/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ module Client
end
end

describe '.type_registry' do
it 'defaults to a TypeRegistry' do
expect(client_class.type_registry).to be_kind_of(Schema::TypeRegistry)
end
end

describe '.set_type_registry' do
it 'can be set' do
registry = Schema::TypeRegistry.new
client_class.set_type_registry = registry
expect(client_class.type_registry).to be(registry)
end
end

describe '.define' do
it 'creates a new client class' do
client_class = Base.define
Expand Down
3 changes: 3 additions & 0 deletions gems/smithy-schema/lib/smithy-schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

require_relative 'smithy-schema/shapes'
require_relative 'smithy-schema/structure'
require_relative 'smithy-schema/document'
require_relative 'smithy-schema/time_helper'
require_relative 'smithy-schema/type_registry'
require_relative 'smithy-schema/union'

module Smithy
Expand Down
100 changes: 100 additions & 0 deletions gems/smithy-schema/lib/smithy-schema/document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require_relative 'document_utils'

module Smithy
module Schema
# A Smithy document type, representing typed or untyped data from Smithy data model.
# ## Document types
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could add more details (e.g. code examples) but I might punt that to the Smithy-Ruby Wiki instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in-code examples/documentation are preferred but it doesn't have to be done this moment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea - I'd agree examples and more detail would be better as in code documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add them now - since information is very fresh in my head.

# Document types are protocol-agnostic view of untyped data. They could be combined
# with a shape to serialize its contents.
#
# Smithy-Ruby currently only support JSON documents.
class Document
# @param [Object] data document data
# @param [Hash] options
# @option options [Smithy::Schema::Structure] :shape shape to reference when setting
# document data. Only applicable when data param is a type of {Shapes::StructureShape}.
# @option options [Boolean] :use_timestamp_format Whether to use the `timestampFormat`
# trait or ignore it when creating a {Document} with given shape. The `timestampFormat`
# trait is ignored by default.
# @option options [Boolean] :use_json_name Whether to use the `jsonName` trait or ignore
# it when creating a {Document} with given shape. The `jsonName` trait is ignored
# by default.
def initialize(data, options = {})
@data = set_data(data, options)
@discriminator = extract_discriminator(data, options)
end

# @return [Object] data
attr_reader :data

# @return [String] discriminator
attr_reader :discriminator

# @param [String] key
# @return [Object]
def [](key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? - I think this class should delegate to the data (perhaps even use Delegator). It should just add some utility to the raw document value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question about this comment but we could discuss it offline.

return unless @data.is_a?(Hash) && @data.key?(key)

@data[key]
end

# @param [Shapes::Structure] shape
# @return [Object] typed shape
def as_typed(shape)
error_message = 'Invalid shape or document data'
raise ArgumentError, error_message unless valid_shape?(shape) && @data.is_a?(Hash)

type = shape.type.new
DocumentUtils.apply(@data, shape, type)
end

private

def discriminator?(data)
data.is_a?(Hash) && data.key?('__type')
end

def extract_discriminator(data, opts)
return if data.nil?

return unless discriminator?(data) || (shape = opts[:shape])

if discriminator?(data)
data['__type']
else
error_message = "Expected a structure shape, given #{shape.class} instead"
raise error_message unless valid_shape?(shape)

shape.id
end
end

def set_data(data, opts)
return if data.nil?

case data
when Smithy::Schema::Structure
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this already "done"? If it's a structure then it's already typed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand on that? what do you mean by being "done" - I still want to re-format the typed shape into a document format.

shape = opts[:shape]
if shape.nil? || !valid_shape?(shape)
raise ArgumentError, "Unable to create a document with given shape: #{shape}"
end

opts = opts.except(:shape)
DocumentUtils.extract(data, shape, opts)
else
if discriminator?(data)
data.except('__type')
else
DocumentUtils.format(data)
end
end
end

def valid_shape?(shape)
shape.is_a?(Shapes::StructureShape) && !shape.type.nil?
end
end
end
end
217 changes: 217 additions & 0 deletions gems/smithy-schema/lib/smithy-schema/document_utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# frozen_string_literal: true

require 'base64'
require 'time'

module Smithy
module Schema
# @api private
# Document Utilities to help (de)construct data to/from Smithy document
module DocumentUtils
class << self
# Used to transform untyped data
def format(data)
return if data.nil?

case data
when Time
data.to_i # timestamp format is "epoch-seconds" by default
when Hash
data.each_with_object({}) do |(k, v), h|
h[k.to_s] = format(v)
end
when Array
data.map { |d| format(d) }
else
data
end
end

# Used to apply data to runtime shape
def apply(data, shape, type = nil)
case shape_reference(shape)
when Shapes::StructureShape then apply_structure(data, shape, type)
when Shapes::UnionShape then apply_union(data, shape, type)
when Shapes::ListShape then apply_list(data, shape)
when Shapes::MapShape then apply_map(data, shape)
when Shapes::TimestampShape then apply_timestamp(data, shape)
when Shapes::BlobShape then Base64.decode64(data)
else data
end
end

# rubocop:disable Metrics/CyclomaticComplexity
def extract(data, shape, opts = {})
return if data.nil?

case shape_reference(shape)
when Shapes::StructureShape then extract_structure(data, shape, opts)
when Shapes::UnionShape then extract_union(data, shape, opts)
when Shapes::ListShape then extract_list(data, shape)
when Shapes::MapShape then extract_map(data, shape)
when Shapes::BlobShape then extract_blob(data)
when Shapes::TimestampShape then extract_timestamp(data, shape, opts)
else data
end
end
# rubocop:enable Metrics/CyclomaticComplexity

private

def apply_list(data, shape)
shape = shape_reference(shape)
data.map do |v|
next if v.nil?

apply(v, shape.member)
end
end

def apply_map(data, shape)
shape = shape_reference(shape)
data.transform_values do |v|
if v.nil?
nil
else
apply(v, shape.value)
end
end
end

def apply_structure(data, shape, type)
shape = shape_reference(shape)

type = shape.type.new if type.nil?
data.each do |k, v|
name =
if (member = member_with_json_name(k, shape))
shape.name_by_member_name(member.name)
else
member_name(shape, k)
end
next if name.nil?

type[name] = apply(v, shape.member(name))
end
type
end

def apply_timestamp(data, shape)
data = data.is_a?(Numeric) ? Time.at(data) : Time.parse(data)
TimeHelper.time(data, timestamp_format(shape))
end

def apply_union(data, shape, type)
shape = shape_reference(shape)
key, value = data.flatten
return if key.nil?

if (member = member_with_json_name(key, shape))
apply_union_member(member.name, value, shape, type)
elsif shape.name_by_member_name?(key)
apply_union_member(key, value, shape, type)
else
shape.member_type(:unknown).new(key, value)
end
end

def apply_union_member(key, value, shape, type)
member_name = shape.name_by_member_name(key)
type = shape.member_type(member_name) if type.nil?
type.new(apply(value, shape.member(member_name)))
end

def extract_blob(data)
Base64.strict_encode64(data.is_a?(String) ? data : data.read)
end

def extract_list(data, shape)
shape = shape_reference(shape)
data.collect { |v| extract(v, shape.member) }
end

def extract_map(data, shape)
shape = shape_reference(shape)
data.each.with_object({}) { |(k, v), h| h[k.to_s] = extract(v, shape.value) }
end

def extract_structure(data, shape, opts)
shape = shape_reference(shape)
data.to_h.each_with_object({}) do |(k, v), o|
next unless shape.member?(k)

member_shape = shape.member(k)
member_name = resolve_member_name(member_shape, opts)
pp member_name
o[member_name] = extract(v, member_shape, opts)
end
end

def extract_timestamp(data, shape, opts)
return unless data.is_a?(Time)

trait = opts[:use_timestamp_format] ? timestamp_format(shape) : 'epoch-seconds'
TimeHelper.time(data, trait)
end

# rubocop:disable Metrics/AbcSize
def extract_union(data, shape, opts)
h = {}
shape = shape_reference(shape)
if data.is_a?(Schema::Union)
member_shape = shape.member_by_type(data.class)
member_name = resolve_member_name(member_shape, opts)
h[member_name] = extract(data, member_shape).value
else
key, value = data.first
if shape.member?(key)
member_shape = shape.member(key)
member_name = resolve_member_name(member_shape, opts)
h[member_name] = extract(value, member_shape)
end
end
h
end
# rubocop:enable Metrics/AbcSize

def member_name(shape, key)
return unless shape.name_by_member_name?(key) || shape.member?(key.to_sym)

shape.name_by_member_name(key) || key.to_sym
end

def member_with_json_name(name, shape)
shape.members.values.find do |v|
v.traits['smithy.api#jsonName'] == name if v.traits.include?('smithy.api#jsonName')
end
end

def resolve_member_name(member_shape, opts)
if opts[:use_json_name] && member_shape.traits['smithy.api#jsonName']
member_shape.traits['smithy.api#jsonName']
else
member_shape.name
end
end

def shape_reference(shape)
shape.is_a?(Shapes::MemberShape) ? shape.shape : shape
end

# The following steps are taken to determine the format of timestamp:
# Use the timestampFormat trait of the member, if present.
# Use the timestampFormat trait of the shape, if present.
# If none of the above applies, use epoch-seconds as default
def timestamp_format(shape)
if shape.traits['smithy.api#timestampFormat']
shape.traits['smithy.api#timestampFormat']
elsif shape.shape.traits['smithy.api#timestampFormat']
shape.shape.traits['smithy.api#timestampFormat']
else
'epoch-seconds'
end
end
end
end
end
end
2 changes: 1 addition & 1 deletion gems/smithy-schema/lib/smithy-schema/structure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def _to_h_array(obj)
end
end

# An empty Struct that includes the {Client::Structure} module.
# An empty Struct that includes the {Schema::Structure} module.
EmptyStructure = Struct.new do
include Smithy::Schema::Structure
end
Expand Down
Loading
Loading