Skip to content

Commit

Permalink
Custom Configuration Annotations (#8)
Browse files Browse the repository at this point in the history
* Extract Serializable module to its own file
* Implement custom configurations annotations
* Add `athena-config` as a dependency
* Add method on `ASR::Context` to get the direction that context object represents
* Bump version
  • Loading branch information
Blacksmoke16 authored Jul 22, 2020
1 parent 73a4b94 commit 22164e1
Show file tree
Hide file tree
Showing 12 changed files with 422 additions and 231 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
image: crystallang/crystal:latest-alpine
steps:
- uses: actions/checkout@v2
- name: Install Dependencies
run: shards install --production --ignore-crystal-version
- name: Specs
run: crystal spec --order random --error-on-warnings
test_nightly:
Expand All @@ -40,5 +42,7 @@ jobs:
image: crystallang/crystal:nightly-alpine
steps:
- uses: actions/checkout@v2
- name: Install Dependencies
run: shards install --production --ignore-crystal-version
- name: Specs
run: crystal spec --order random --error-on-warnings
6 changes: 4 additions & 2 deletions .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ jobs:
container:
image: crystallang/crystal
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2
- name: Install Dependencies
run: shards install --production
- name: Build
run: crystal docs
run: crystal docs lib/athena-config/src/athena-config.cr src/athena-serializer.cr
- name: Deploy
uses: JamesIves/github-pages-deploy-action@2.0.3
env:
Expand Down
7 changes: 6 additions & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: athena-serializer

version: 0.1.3
version: 0.2.0

crystal: 0.35.0

Expand All @@ -16,6 +16,11 @@ description: |
authors:
- George Dietrich <george@dietrich.app>

dependencies:
athena-config:
github: athena-framework/config
version: ~> 0.1.2

development_dependencies:
ameba:
github: crystal-ameba/ameba
Expand Down
49 changes: 49 additions & 0 deletions spec/exclusion_strategies/custom_strategy_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require "../spec_helper"

ACF.configuration_annotation IsActiveProperty, active : Bool = true

private struct ActivePropertyExclusionStrategy
include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface

# :inherit:
def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool
return false if context.direction.deserialization?

ann_configs = metadata.annotation_configurations

ann_configs.has?(IsActiveProperty) && !ann_configs[IsActiveProperty].active
end
end

# Mainly testing `Athena::Config` integration in regards to custom annotations accessable via the property metadata.
describe ActivePropertyExclusionStrategy do
describe "#skip_property?" do
describe :deserialization do
it "it should not skip" do
ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::DeserializationContext.new).should be_false
end
end

describe :serialization do
describe "without the annotation" do
it "should not skip" do
ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::SerializationContext.new).should be_false
end
end

describe "with the annotation" do
it true do
ann_config = ACF::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new] of ACF::AnnotationConfigurations::ConfigurationBase})

ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_false
end

it false do
ann_config = ACF::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new(false)] of ACF::AnnotationConfigurations::ConfigurationBase})

ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_true
end
end
end
end
end
14 changes: 12 additions & 2 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def get_test_property_metadata : Array(ASR::PropertyMetadataBase)
[ASR::PropertyMetadata(String, String, TestObject).new(
name: "name",
external_name: "external_name",
annotation_configurations: ACF::AnnotationConfigurations.new,
value: "YES",
skip_when_empty: false,
groups: ["default"],
Expand Down Expand Up @@ -129,8 +130,17 @@ def assert_deserialized_output(visitor_type : ASR::Visitors::DeserializationVisi
result.should eq expected
end

def create_metadata(*, name : String = "name", external_name : String = "external_name", value : I = "value", skip_when_empty : Bool = false, groups : Array(String) = ["default"], since_version : String? = nil, until_version : String? = nil) : ASR::PropertyMetadata forall I
context = ASR::PropertyMetadata(I, I, EmptyObject).new name, external_name, value, skip_when_empty, groups
def create_metadata(
*,
name : String = "name",
external_name : String = "external_name",
value : I = "value", skip_when_empty : Bool = false,
groups : Array(String) = ["default"],
since_version : String? = nil,
until_version : String? = nil,
annotation_configurations : ACF::AnnotationConfigurations = ACF::AnnotationConfigurations.new
) : ASR::PropertyMetadata forall I
context = ASR::PropertyMetadata(I, I, EmptyObject).new name, external_name, annotation_configurations, value, skip_when_empty, groups

context.since_version = SemanticVersion.parse since_version if since_version
context.until_version = SemanticVersion.parse until_version if until_version
Expand Down
230 changes: 4 additions & 226 deletions src/athena-serializer.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ require "uuid"
require "json"
require "yaml"

require "athena-config"

require "./annotations"
require "./any"
require "./context"
require "./serializable"
require "./serializer_interface"
require "./serializer"
require "./property_metadata"
Expand Down Expand Up @@ -44,7 +47,7 @@ module YAML; end
# dependencies:
# athena-serializer:
# github: athena-framework/serializer
# version: ~> 0.1.0
# version: ~> 0.2.0
# ```
#
# Run `shards install`.
Expand Down Expand Up @@ -178,229 +181,4 @@ module Athena::Serializer
# record Unionable, type : BaseModel.class
# ```
module Athena::Serializer::Model; end

# Adds the necessary methods to a `struct`/`class` to allow for (de)serialization of that type.
#
# ```
# require "athena-serializer"
#
# record Example, id : Int32, name : String do
# include ASR::Serializable
# end
#
# obj = ASR.serializer.deserialize Example, %({"id":1,"name":"George"}), :json
# obj # => Example(@id=1, @name="George")
# ASR.serializer.serialize obj, :yaml # =>
# # ---
# # id: 1
# # name: George
# ```
module Serializable
# :nodoc:
abstract def serialization_properties : Array(ASR::PropertyMetadataBase)

# :nodoc:
abstract def run_preserialize : Nil

# :nodoc:
abstract def run_postserialize : Nil

# :nodoc:
abstract def run_postdeserialize : Nil

macro included
{% verbatim do %}
include ASR::Model

# :nodoc:
def run_preserialize : Nil
{% for method in @type.methods.select { |m| m.annotation(ASRA::PreSerialize) } %}
{{method.name}}
{% end %}
end

# :nodoc:
def run_postserialize : Nil
{% for method in @type.methods.select { |m| m.annotation(ASRA::PostSerialize) } %}
{{method.name}}
{% end %}
end

# :nodoc:
def run_postdeserialize : Nil
{% for method in @type.methods.select { |m| m.annotation(ASRA::PostDeserialize) } %}
{{method.name}}
{% end %}
end

# :nodoc:
def serialization_properties : Array(ASR::PropertyMetadataBase)
{% begin %}
# Construct the array of metadata from the properties on `self`.
# Takes into consideration some annotations to control how/when a property should be serialized
{%
instance_vars = @type.instance_vars
.reject { |ivar| ivar.annotation(ASRA::Skip) }
.reject { |ivar| ivar.annotation(ASRA::IgnoreOnSerialize) }
.reject do |ivar|
not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose)
excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude)

!ivar.annotation(ASRA::IgnoreOnDeserialize) && (not_exposed || excluded)
end
%}

{% property_hash = {} of Nil => Nil %}

{% for ivar in instance_vars %}
{% ivar_name = ivar.name.stringify %}

# Determine the serialized name of the ivar:
# 1. If the ivar has an `ASRA::Name` annotation with a `serialize` field, use that
# 2. If the type has an `ASRA::Name` annotation with a `strategy`, use that strategy
# 3. Fallback on the name of the ivar
{% external_name = if (name_ann = ivar.annotation(ASRA::Name)) && (serialized_name = name_ann[:serialize])
serialized_name
elsif (name_ann = @type.annotation(ASRA::Name)) && (strategy = name_ann[:strategy])
if strategy == :camelcase
ivar_name.camelcase lower: true
elsif strategy == :underscore
ivar_name.underscore
elsif strategy == :identical
ivar_name
else
strategy.raise "Invalid ASRA::Name strategy: '#{strategy}'."
end
else
ivar_name
end %}

{% property_hash[external_name] = %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}, #{@type}).new(
name: #{ivar.name.stringify},
external_name: #{external_name},
value: #{(accessor = ivar.annotation(ASRA::Accessor)) && accessor[:getter] != nil ? accessor[:getter].id : %(@#{ivar.id}).id},
skip_when_empty: #{!!ivar.annotation(ASRA::SkipWhenEmpty)},
groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]},
since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil},
until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil},
)).id %}
{% end %}

{% for m in @type.methods.select { |method| method.annotation(ASRA::VirtualProperty) } %}
{% method_name = m.name %}
{% m.raise "ASRA::VirtualProperty return type must be set for '#{@type.name}##{method_name}'." if m.return_type.is_a? Nop %}
{% external_name = (ann = m.annotation(ASRA::Name)) && (name = ann[:serialize]) ? name : m.name.stringify %}

{% property_hash[external_name] = %(ASR::PropertyMetadata(#{m.return_type}, #{m.return_type}, #{@type}).new(
name: #{m.name.stringify},
external_name: #{external_name},
value: #{m.name.id},
skip_when_empty: #{!!m.annotation(ASRA::SkipWhenEmpty)},
)).id %}
{% end %}

{% if (ann = @type.annotation(ASRA::AccessorOrder)) && !ann[0].nil? %}
{% if ann[0] == :alphabetical %}
{% properties = property_hash.keys.sort.map { |key| property_hash[key] } %}
{% elsif ann[0] == :custom && !ann[:order].nil? %}
{% ann.raise "Not all properties were defined in the custom order for '#{@type}'." unless property_hash.keys.all? { |prop| ann[:order].map(&.id.stringify).includes? prop } %}
{% properties = ann[:order].map { |val| property_hash[val.id.stringify] || raise "Unknown instance variable: '#{val.id}'." } %}
{% else %}
{% ann.raise "Invalid ASR::AccessorOrder value: '#{ann[0].id}'." %}
{% end %}
{% else %}
{% properties = property_hash.values %}
{% end %}

{{properties}} of ASR::PropertyMetadataBase
{% end %}
end

# :nodoc:
def self.deserialization_properties : Array(ASR::PropertyMetadataBase)
{% verbatim do %}
{% begin %}
# Construct the array of metadata from the properties on `self`.
# Takes into consideration some annotations to control how/when a property should be serialized
{% instance_vars = @type.instance_vars
.reject { |ivar| ivar.annotation(ASRA::Skip) }
.reject { |ivar| (ann = ivar.annotation(ASRA::ReadOnly)); ann && !ivar.has_default_value? && !ivar.type.nilable? ? ivar.raise "#{@type}##{ivar.name} is read-only but is not nilable nor has a default value" : ann }
.reject { |ivar| ivar.annotation(ASRA::IgnoreOnDeserialize) }
.reject do |ivar|
not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose)
excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude)

!ivar.annotation(ASRA::IgnoreOnSerialize) && (not_exposed || excluded)
end %}

{{instance_vars.map do |ivar|
%(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}?, #{@type}).new(
name: #{ivar.name.stringify},
external_name: #{(ann = ivar.annotation(ASRA::Name)) && (name = ann[:deserialize]) ? name : ivar.name.stringify},
aliases: #{(ann = ivar.annotation(ASRA::Name)) && (aliases = ann[:aliases]) ? aliases : "[] of String".id},
groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]},
since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil},
until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil},
)).id
end}} of ASR::PropertyMetadataBase
{% end %}
{% end %}
end

# :nodoc:
def apply(navigator : ASR::Navigators::DeserializationNavigator, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any)
self.initialize navigator, properties, data
end

# :nodoc:
def initialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any)
{% begin %}
{% for ivar, idx in @type.instance_vars %}
if (prop = properties.find { |p| p.name == {{ivar.name.stringify}} }) && (val = extract_value(prop, data, {{(ann = ivar.annotation(ASRA::Accessor)) ? ann[:path] : nil}}))
value = {% if (ann = ivar.annotation(ASRA::Accessor)) && (converter = ann[:converter]) %}
{{converter.id}}.deserialize navigator, prop, val
{% else %}
navigator.accept {{ivar.type}}, val
{% end %}

unless value.nil?
@{{ivar.id}} = value
else
{% if !ivar.type.nilable? && !ivar.has_default_value? %}
raise Exception.new "Required property '{{ivar}}' cannot be nil."
{% end %}
end
else
{% if !ivar.type.nilable? && !ivar.has_default_value? %}
raise Exception.new "Missing required attribute: '{{ivar}}'."
{% end %}
end

{% if (ann = ivar.annotation(ASRA::Accessor)) && (setter = ann[:setter]) %}
self.{{setter.id}}(@{{ivar.id}})
{% end %}
{% end %}
{% end %}
end

# Attempts to extract a value from the *data* for the given *property*.
# Returns `nil` if a value could not be extracted.
private def extract_value(property : ASR::PropertyMetadataBase, data : ASR::Any, path : Tuple?) : ASR::Any?
if path && (value = data.dig?(*path))
return value
end

if (key = property.aliases.find { |a| data[a]? }) && (value = data[key]?)
return value
end

if value = data[property.external_name]?
return value
end

nil
end
{% end %}
end
end
end
Loading

0 comments on commit 22164e1

Please sign in to comment.