-
Notifications
You must be signed in to change notification settings - Fork 6
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
base: decaf
Are you sure you want to change the base?
Changes from 22 commits
d33fcdf
5ff40cc
66a6285
c5e45ed
2bd15a2
03bfa82
e6435d5
0830827
877654f
a61318f
4edfae3
ff959f1
8b9b560
598db66
3a4c0d1
269b2b5
90c58ce
a1e46cc
8b666cd
2ddf4bd
112ddf4
6283813
efbfa5e
88ff845
66b2cde
22998a0
9afeacd
232844c
e8920fd
ef8c027
3854661
48e1b0f
04d0d5b
b35e09b
66825be
4efe669
73e0e83
60ef29c
b67cf54
ea9b380
381602b
6d41f64
83fa2bb
b23a2e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
# 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 | ||
# Document types are protocol-agnostic view of untyped data. They could be combined | ||
# with a schema to serialize its contents. | ||
# | ||
# Smithy-Ruby currently only support JSON documents. | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
class Document | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# @param [Object] data document data | ||
# @param [Hash] options | ||
# @option options [Smithy::Schema::Structure] :schema schema 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 schema. 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 schema. The `jsonName` trait is ignored | ||
# by default. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was pretty torn on whether I want to have these options around. These options only applies when we create a document with a given schema to decide whether we want to honor specific traits in the My idea is that once There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you are referring to the use_x options, I think they're pretty useless, and can always be added later. I'd start with them removed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed that there's test cases now, I might see what they ended up doing to determine whether i want to keep these options around. |
||
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 [Object] key | ||
# @return [Object] | ||
def [](key) | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return unless @data.is_a?(Hash) && @data.key?(key) | ||
|
||
@data[key] | ||
end | ||
|
||
# @param [Shapes::Shape] schema | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# @return [Shapes::Structure] typed shape | ||
def as_typed(schema) | ||
error_message = 'Invalid schema or document data' | ||
raise ArgumentError, error_message unless valid_schema?(schema) && @data.is_a?(Hash) | ||
|
||
type = schema.type.new | ||
DocumentUtils.apply(@data, schema, 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) || (schema = opts[:schema]) | ||
|
||
if discriminator?(data) | ||
data['__type'] | ||
else | ||
error_message = "Expected a structure schema, given #{schema.class} instead" | ||
raise error_message unless valid_schema?(schema) | ||
|
||
schema.id | ||
end | ||
end | ||
|
||
def set_data(data, opts) | ||
return if data.nil? | ||
|
||
case data | ||
when Smithy::Schema::Structure | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
schema = opts[:schema] | ||
if schema.nil? || !valid_schema?(schema) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does still seem weird to me that the run time type doesn't know its own schema There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be useful to have that information at hand but we had some previous discussion on whether this should be a thing or not - based on context of typed shapes being PORO . I don't have a strong opinion but I think starting out without is fine until we have a use case where we absolutely need them to. |
||
raise ArgumentError, "Unable to create a document with given schema: #{schema}" | ||
end | ||
|
||
opts = opts.except(:schema) | ||
# case 1 - extract data from runtime shape, schema is required to know to properly extract | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
DocumentUtils.extract(data, schema, opts) | ||
|
||
else | ||
if discriminator?(data) | ||
# case 2 - extract typed data from parsed JSON | ||
data.except('__type') | ||
else | ||
# case 3 - untyped data, we will need consolidate timestamps and such | ||
DocumentUtils.format(data) | ||
end | ||
end | ||
end | ||
|
||
def valid_schema?(schema) | ||
schema.is_a?(Shapes::StructureShape) && !schema.type.nil? | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
# 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 | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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.transform_values { |v| format(v) } | ||
when Array | ||
data.map { |d| format(d) } | ||
else | ||
data | ||
end | ||
end | ||
|
||
# Used to apply data to runtime shape | ||
def apply(data, schema, type = nil) | ||
case shape(schema) | ||
when Shapes::StructureShape then apply_structure(data, schema, type) | ||
when Shapes::UnionShape then apply_union(data, schema, type) | ||
when Shapes::ListShape then apply_list(data, schema) | ||
when Shapes::MapShape then apply_map(data, schema) | ||
when Shapes::TimestampShape then apply_timestamp(data, schema) | ||
when Shapes::BlobShape then Base64.decode64(data) | ||
else data | ||
end | ||
end | ||
|
||
# rubocop:disable Metrics/CyclomaticComplexity | ||
def extract(data, schema, opts = {}) | ||
return if data.nil? | ||
|
||
case shape(schema) | ||
when Shapes::StructureShape then extract_structure(data, schema, opts) | ||
when Shapes::UnionShape then extract_union(data, schema, opts) | ||
when Shapes::ListShape then extract_list(data, schema) | ||
when Shapes::MapShape then extract_map(data, schema) | ||
when Shapes::BlobShape then extract_blob(data) | ||
when Shapes::TimestampShape then extract_timestamp(data, schema, opts) | ||
else data | ||
end | ||
end | ||
# rubocop:enable Metrics/CyclomaticComplexity | ||
|
||
private | ||
|
||
def apply_list(data, schema) | ||
shape = shape(schema) | ||
data.map do |v| | ||
next if v.nil? | ||
|
||
apply(v, shape.member) | ||
end | ||
end | ||
|
||
def apply_map(data, schema) | ||
shape = shape(schema) | ||
data.transform_values do |v| | ||
if v.nil? | ||
nil | ||
else | ||
apply(v, shape.value) | ||
end | ||
end | ||
end | ||
|
||
def apply_structure(data, schema, type) | ||
shape = shape(schema) | ||
|
||
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, schema) | ||
data = data.is_a?(Numeric) ? Time.at(data) : Time.parse(data) | ||
time(data, timestamp_format(schema)) | ||
end | ||
|
||
def apply_union(data, schema, type) | ||
shape = shape(schema) | ||
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) | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Base64.strict_encode64(data.is_a?(String) ? data : data.read) | ||
end | ||
|
||
def extract_list(data, schema) | ||
shape = shape(schema) | ||
data.collect { |v| extract(v, shape.member) } | ||
end | ||
|
||
def extract_map(data, schema) | ||
shape = shape(schema) | ||
data.each.with_object({}) { |(k, v), h| h[k] = extract(v, shape.value) } | ||
end | ||
|
||
def extract_structure(data, schema, opts) | ||
shape = shape(schema) | ||
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) | ||
o[member_name] = extract(v, member_shape, opts) | ||
end | ||
end | ||
|
||
def extract_timestamp(data, schema, opts) | ||
return unless data.is_a?(Time) | ||
|
||
trait = timestamp_format(schema) if opts[:use_timestamp_format] | ||
time(data, trait) | ||
end | ||
|
||
# rubocop:disable Metrics/AbcSize | ||
def extract_union(data, schema, opts) | ||
h = {} | ||
shape = shape(schema) | ||
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(schema, key) | ||
return unless schema.name_by_member_name?(key) || schema.member?(key.to_sym) | ||
|
||
schema.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(schema) | ||
schema.is_a?(Shapes::MemberShape) ? schema.shape : schema | ||
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(schema) | ||
if schema.traits['smithy.api#timestampFormat'] | ||
schema.traits['smithy.api#timestampFormat'] | ||
elsif schema.shape.traits['smithy.api#timestampFormat'] | ||
schema.shape.traits['smithy.api#timestampFormat'] | ||
else | ||
'epoch-seconds' | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
end | ||
end | ||
|
||
def time(data, trait = nil) | ||
jterapin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if trait | ||
case trait | ||
when 'http-date' | ||
data.utc.iso8601 | ||
when 'date-time' | ||
data.utc.httpdate | ||
when 'epoch-seconds' | ||
data.utc.to_i | ||
else | ||
raise "unhandled timestamp format `#{value}`" | ||
end | ||
else | ||
data.utc.to_i # default format | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.