Skip to content

Commit

Permalink
feat: add basic JSON serializer and supporting classes
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink committed Feb 14, 2024
1 parent 479f285 commit 0c149d1
Show file tree
Hide file tree
Showing 33 changed files with 3,991 additions and 7 deletions.
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ end

group :development, :test do
gem "minitest"
gem "minitest-focus"
gem "minitest-reporters"

gem "debug"

gem "sorbet-struct-comparable"
end
20 changes: 20 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ PATH
remote: .
specs:
sorbet-schema (0.1.0)
sorbet-result (~> 1.0)
sorbet-runtime (~> 0.5)
zeitwerk (~> 2.6)

GEM
remote: https://rubygems.org/
specs:
ansi (1.5.0)
ast (2.4.2)
builder (3.2.4)
debug (1.9.1)
irb (~> 1.10)
reline (>= 0.3.8)
Expand All @@ -20,6 +24,13 @@ GEM
language_server-protocol (3.17.0.3)
lint_roller (1.1.0)
minitest (5.21.2)
minitest-focus (1.4.0)
minitest (>= 4, < 6)
minitest-reporters (1.6.1)
ansi
builder
minitest (>= 5.0)
ruby-progressbar
netrc (0.11.0)
parallel (1.24.0)
parser (3.3.0.5)
Expand Down Expand Up @@ -62,12 +73,17 @@ GEM
ruby-progressbar (1.13.0)
sorbet (0.5.11221)
sorbet-static (= 0.5.11221)
sorbet-result (1.0.0)
sorbet-runtime (~> 0.5)
zeitwerk (~> 2.6)
sorbet-runtime (0.5.11221)
sorbet-static (0.5.11221-universal-darwin)
sorbet-static (0.5.11221-x86_64-linux)
sorbet-static-and-runtime (0.5.11221)
sorbet (= 0.5.11221)
sorbet-runtime (= 0.5.11221)
sorbet-struct-comparable (1.3.0)
sorbet-runtime (>= 0.5)
spoom (1.2.4)
erubi (>= 1.10.0)
sorbet-static-and-runtime (>= 0.5.10187)
Expand Down Expand Up @@ -106,6 +122,7 @@ GEM
yard-sorbet (0.8.1)
sorbet-runtime (>= 0.5)
yard (>= 0.9)
zeitwerk (2.6.13)

PLATFORMS
arm64-darwin-22
Expand All @@ -115,9 +132,12 @@ PLATFORMS
DEPENDENCIES
debug
minitest
minitest-focus
minitest-reporters
rake
sorbet
sorbet-schema!
sorbet-struct-comparable
standard
standard-performance
standard-sorbet
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2022 Max VelDink
Copyright (c) 2024 Max VelDink

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
7 changes: 1 addition & 6 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ end

require "standard/rake"

desc "Test Compiler output"
task :compiler do
sh "./test/test_type_checker.sh"
end

desc "Run tapioca compilers"
task :tapioca do
sh "bin/tapioca gem"
Expand All @@ -24,4 +19,4 @@ task :sorbet do
sh "bundle exec srb tc"
end

task default: %i[standard:fix_unsafely sorbet test compiler]
task default: %i[standard:fix_unsafely sorbet test]
18 changes: 18 additions & 0 deletions lib/sorbet-schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "sorbet-result"

# We can't use `Loader.for_gem` here as we've unconventionally named the root file.
require "zeitwerk"
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__.to_s)
loader.ignore(__FILE__)
loader.inflector.inflect(
"json_serializer" => "JSONSerializer"
)
loader.setup

# Sorbet-aware namespace to super-charge your projects
module Typed; end
33 changes: 33 additions & 0 deletions lib/typed/apply_validators.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# typed: strict

module Typed
class ApplyValidators
extend T::Sig

sig { params(schema: Schema).void }
def initialize(schema:)
@schema = schema
end

sig { params(params: Serializer::Params).returns(Result[Serializer::Params, ValidationError]) }
def call(params)
failing_results = schema.fields.map do |field|
field.validate(params[field.name])
end.select(&:failure?)

case failing_results.length
when 0
Success.new(params)
when 1
Failure.new(T.must(failing_results.first).error)
else
Failure.new(MultipleValidationError.new(errors: failing_results.map(&:error)))
end
end

private

sig { returns(Schema) }
attr_reader :schema
end
end
6 changes: 6 additions & 0 deletions lib/typed/deserialize_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# typed: strict

module Typed
class DeserializeError < StandardError
end
end
39 changes: 39 additions & 0 deletions lib/typed/field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# typed: strict

module Typed
class Field < T::Struct
extend T::Sig

const :name, Symbol
const :type, T::Class[T.anything]
const :required, T::Boolean, default: true

ValidationResult = T.type_alias { Result[T.untyped, ValidationError] }

sig { returns(T::Boolean) }
def required?
required
end

sig { returns(T::Boolean) }
def optional?
!required
end

sig { params(value: T.untyped).returns(ValidationResult) }
def validate(value)
validate_required(value)
end

private

sig { params(value: T.untyped).returns(ValidationResult) }
def validate_required(value)
if required? && value.nil?
Failure.new(RequiredFieldError.new(field_name: name))
else
Success.new(value)
end
end
end
end
32 changes: 32 additions & 0 deletions lib/typed/json_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# typed: strict

require "json"

module Typed
class JSONSerializer < Serializer
extend T::Sig

sig { override.params(source: String).returns(Result[T::Struct, DeserializeError]) }
def deserialize(source)
parsed_json = JSON.parse(source)

creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
hsh[field.name] = parsed_json[field.name.to_s]
end

ApplyValidators
.new(schema:)
.call(creation_params)
.and_then do |validated_params|
Success.new(schema.target.new(**validated_params))
end
rescue JSON::ParserError
Failure.new(ParseError.new(format: :json))
end

sig { override.params(struct: T::Struct).returns(String) }
def serialize(struct)
JSON.generate(struct.serialize)
end
end
end
14 changes: 14 additions & 0 deletions lib/typed/multiple_validation_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# typed: strict

module Typed
class MultipleValidationError < ValidationError
extend T::Sig

sig { params(errors: T::Array[ValidationError]).void }
def initialize(errors:)
combined_message = errors.map(&:message).join(" | ")

super("Multiple validation errors found: #{combined_message}")
end
end
end
12 changes: 12 additions & 0 deletions lib/typed/parse_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# typed: strict

module Typed
class ParseError < DeserializeError
extend T::Sig

sig { params(format: Symbol).void }
def initialize(format:)
super("#{format} could not be parsed. Check for typos.")
end
end
end
12 changes: 12 additions & 0 deletions lib/typed/required_field_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# typed: strict

module Typed
class RequiredFieldError < ValidationError
extend T::Sig

sig { params(field_name: Symbol).void }
def initialize(field_name:)
super("#{field_name} is required.")
end
end
end
8 changes: 8 additions & 0 deletions lib/typed/schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# typed: strict

module Typed
class Schema < T::Struct
const :fields, T::Array[Field], default: []
const :target, T.class_of(T::Struct)
end
end
27 changes: 27 additions & 0 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# typed: strict

module Typed
class Serializer
extend T::Sig
extend T::Helpers
abstract!

Params = T.type_alias { T::Hash[Symbol, T.untyped] }

sig { returns(Schema) }
attr_reader :schema

sig { params(schema: Schema).void }
def initialize(schema:)
@schema = schema
end

sig { abstract.params(source: String).returns(Typed::Result[T::Struct, DeserializeError]) }
def deserialize(source)
end

sig { abstract.params(struct: T::Struct).returns(String) }
def serialize(struct)
end
end
end
6 changes: 6 additions & 0 deletions lib/typed/validation_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# typed: strict

module Typed
class ValidationError < DeserializeError
end
end
2 changes: 2 additions & 0 deletions sorbet-schema.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]

spec.add_runtime_dependency "sorbet-result", "~> 1.0"
spec.add_runtime_dependency "sorbet-runtime", "~> 0.5"
spec.add_runtime_dependency "zeitwerk", "~> 2.6"
end
Loading

0 comments on commit 0c149d1

Please sign in to comment.