sober_swag 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -0
  3. data/.github/workflows/lint.yml +15 -0
  4. data/.github/workflows/ruby.yml +33 -2
  5. data/.gitignore +4 -0
  6. data/.rubocop.yml +75 -1
  7. data/.ruby-version +1 -1
  8. data/README.md +154 -1
  9. data/bin/console +16 -15
  10. data/docs/serializers.md +203 -0
  11. data/example/.rspec +1 -0
  12. data/example/.ruby-version +1 -1
  13. data/example/Gemfile +9 -7
  14. data/example/Gemfile.lock +96 -79
  15. data/example/app/controllers/people_controller.rb +41 -23
  16. data/example/app/controllers/posts_controller.rb +110 -0
  17. data/example/app/models/application_record.rb +3 -0
  18. data/example/app/models/person.rb +6 -0
  19. data/example/app/models/post.rb +9 -0
  20. data/example/app/output_objects/person_errors_output_object.rb +5 -0
  21. data/example/app/output_objects/person_output_object.rb +15 -0
  22. data/example/app/output_objects/post_output_object.rb +10 -0
  23. data/example/bin/bundle +24 -20
  24. data/example/bin/rails +1 -1
  25. data/example/bin/rake +1 -1
  26. data/example/config/application.rb +11 -7
  27. data/example/config/environments/development.rb +0 -1
  28. data/example/config/environments/production.rb +3 -3
  29. data/example/config/puma.rb +5 -5
  30. data/example/config/routes.rb +3 -0
  31. data/example/config/spring.rb +4 -4
  32. data/example/db/migrate/20200311152021_create_people.rb +0 -1
  33. data/example/db/migrate/20200603172347_create_posts.rb +11 -0
  34. data/example/db/schema.rb +16 -7
  35. data/example/spec/rails_helper.rb +64 -0
  36. data/example/spec/requests/people/create_spec.rb +52 -0
  37. data/example/spec/requests/people/get_spec.rb +35 -0
  38. data/example/spec/requests/people/index_spec.rb +69 -0
  39. data/example/spec/spec_helper.rb +94 -0
  40. data/lib/sober_swag.rb +6 -3
  41. data/lib/sober_swag/compiler/error.rb +2 -0
  42. data/lib/sober_swag/compiler/path.rb +2 -5
  43. data/lib/sober_swag/compiler/paths.rb +0 -1
  44. data/lib/sober_swag/compiler/type.rb +86 -56
  45. data/lib/sober_swag/controller.rb +16 -11
  46. data/lib/sober_swag/controller/route.rb +18 -21
  47. data/lib/sober_swag/controller/undefined_body_error.rb +3 -0
  48. data/lib/sober_swag/controller/undefined_path_error.rb +3 -0
  49. data/lib/sober_swag/controller/undefined_query_error.rb +3 -0
  50. data/lib/sober_swag/input_object.rb +28 -0
  51. data/lib/sober_swag/nodes/array.rb +1 -1
  52. data/lib/sober_swag/nodes/base.rb +5 -3
  53. data/lib/sober_swag/nodes/binary.rb +2 -1
  54. data/lib/sober_swag/nodes/enum.rb +4 -2
  55. data/lib/sober_swag/nodes/list.rb +0 -1
  56. data/lib/sober_swag/nodes/primitive.rb +6 -5
  57. data/lib/sober_swag/output_object.rb +102 -0
  58. data/lib/sober_swag/output_object/definition.rb +30 -0
  59. data/lib/sober_swag/{blueprint → output_object}/field.rb +14 -4
  60. data/lib/sober_swag/{blueprint → output_object}/field_syntax.rb +2 -2
  61. data/lib/sober_swag/{blueprint → output_object}/view.rb +15 -6
  62. data/lib/sober_swag/parser.rb +9 -4
  63. data/lib/sober_swag/serializer.rb +5 -2
  64. data/lib/sober_swag/serializer/array.rb +12 -0
  65. data/lib/sober_swag/serializer/base.rb +50 -1
  66. data/lib/sober_swag/serializer/conditional.rb +19 -2
  67. data/lib/sober_swag/serializer/field_list.rb +29 -6
  68. data/lib/sober_swag/serializer/mapped.rb +15 -3
  69. data/lib/sober_swag/serializer/meta.rb +35 -0
  70. data/lib/sober_swag/serializer/optional.rb +17 -2
  71. data/lib/sober_swag/serializer/primitive.rb +4 -1
  72. data/lib/sober_swag/server.rb +83 -0
  73. data/lib/sober_swag/types.rb +3 -0
  74. data/lib/sober_swag/version.rb +1 -1
  75. data/sober_swag.gemspec +8 -4
  76. metadata +79 -47
  77. data/Gemfile.lock +0 -92
  78. data/example/person.json +0 -4
  79. data/example/test/controllers/.keep +0 -0
  80. data/example/test/fixtures/.keep +0 -0
  81. data/example/test/fixtures/files/.keep +0 -0
  82. data/example/test/fixtures/people.yml +0 -11
  83. data/example/test/integration/.keep +0 -0
  84. data/example/test/models/.keep +0 -0
  85. data/example/test/models/person_test.rb +0 -7
  86. data/example/test/test_helper.rb +0 -13
  87. data/lib/sober_swag/blueprint.rb +0 -113
  88. data/lib/sober_swag/path.rb +0 -8
  89. data/lib/sober_swag/path/integer.rb +0 -21
  90. data/lib/sober_swag/path/lit.rb +0 -41
  91. data/lib/sober_swag/path/literal.rb +0 -29
  92. data/lib/sober_swag/path/param.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e58f142c7c7e5716d0d023844846ffcac306ff45f6ab84650cdf33003898ff83
4
- data.tar.gz: 33e35fb1910ebe36f1618b2091e0cfb7762dbadbebaba046c8a67d3f58038e24
3
+ metadata.gz: cdf3896754f6acf3bef2df51e947439be3822d3635eb924d6029ab3157b13bd9
4
+ data.tar.gz: 75846c0b78e4e8dc9fc550af50aeb2bb73f3c1200e8c50af6c07092d2dd8c277
5
5
  SHA512:
6
- metadata.gz: 747f4d9f9c0750de1a8109fb443bbe21df72929f74f447ce1473d676b2dcbc952c52dcc7731c6cd097d6f06452220ee808b883d4529da458b89baa2dde84ee76
7
- data.tar.gz: 839284d0a57c363348e486320256c3f4d097b4c193eab3914416037a178a4a32e7b07e236e72b77376a68fce9715ffe62898a2b8ef7659fb9f2ccbf3a7b01378
6
+ metadata.gz: ae7076a9dcabcf2a99b642f967782195d1680ff13b6fd251123abdec15ec2eab18435ec1897fe212a060779b1bdffb8061bcb4b2eb89884713230ca0c70ecc27
7
+ data.tar.gz: 5f3fb9ee3c31feb8ede072171e5f0f2f5108061d7f09c44e4f4d88d929c001519734fce64b4c261ea4d8e63d9aa1862fdf374cdddd00da2b58d9cf75fc367320
@@ -0,0 +1,4 @@
1
+ check_name: 'Rubocop Lint'
2
+ versions:
3
+ rubocop: 'latest'
4
+ rubocop-rspec: 'latest'
@@ -0,0 +1,15 @@
1
+ name: Linters
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v1
10
+ - name: Rubocop Linter
11
+ uses: andrewmcodes/rubocop-linter-action@v3.0.0
12
+ with:
13
+ action_config_path: '.github/config/rubocop_linter_action.yml' # Note: this is the default location
14
+ env:
15
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -17,6 +17,9 @@ jobs:
17
17
  test:
18
18
 
19
19
  runs-on: ubuntu-latest
20
+ strategy:
21
+ matrix:
22
+ ruby: [ '2.6', '2.7' ]
20
23
 
21
24
  steps:
22
25
  - uses: actions/checkout@v2
@@ -26,8 +29,36 @@ jobs:
26
29
  # uses: ruby/setup-ruby@v1
27
30
  uses: ruby/setup-ruby@ec106b438a1ff6ff109590de34ddc62c540232e0
28
31
  with:
29
- ruby-version: 2.7
32
+ ruby-version: ${{ matrix.ruby }}
33
+ - uses: actions/cache@v2
34
+ with:
35
+ path: vendor/bundle
36
+ key: ${{ runner.os }}-${{ matrix.ruby }}-gem-deps-${{ hashFiles('**/Gemfile.lock') }}
37
+ restore-keys: |
38
+ ${{ runner.os }}-${{ matrix.ruby }}-gem-deps-
30
39
  - name: Install dependencies
31
- run: bundle install
40
+ run: |
41
+ bundle config path vendor/bundle
42
+ bundle install
32
43
  - name: Run tests
44
+ run: COVERAGE=1 bundle exec rake
45
+ - name: Upload Coverage
46
+ uses: actions/upload-artifact@master
47
+ if: always()
48
+ with:
49
+ name: coverage-report
50
+ path: coverage
51
+ - uses: actions/cache@v2
52
+ with:
53
+ path: example/vendor/bundle
54
+ key: ${{ runner.os }}-${{ matrix.ruby }}-example-deps-${{ hashFiles('example/**/Gemfile.lock') }}
55
+ restore-keys: |
56
+ ${{ runner.os }}-${{ matrix.ruby }}-example-deps-
57
+ - name: Install example dependencies for example
58
+ working-directory: example
59
+ run: |
60
+ bundle config path vendor/bundle
61
+ bundle install
62
+ - name: Run specs for example
63
+ working-directory: example
33
64
  run: bundle exec rake
data/.gitignore CHANGED
@@ -9,3 +9,7 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ .ruby-version
13
+ Gemfile.lock
14
+
15
+ *.gem
@@ -3,5 +3,79 @@ Style/FrozenStringLiteralComment:
3
3
  Style/BlockDelimiters:
4
4
  EnforcedStyle: braces_for_chaining
5
5
  AllCops:
6
- TargetRubyVersion: 2.7.0
6
+ TargetRubyVersion: 2.6.0
7
+ Exclude:
8
+ - 'bin/bundle'
9
+ - 'example/bin/bundle'
10
+
11
+ Layout/LineLength:
12
+ Max: 160
7
13
  require: rubocop-rspec
14
+ RSpec/NamedSubject:
15
+ Enabled: false
16
+ RSpec/DescribeClass:
17
+ Enabled: false
18
+ Metrics/BlockLength:
19
+ Exclude:
20
+ - 'spec/**/*.rb'
21
+ - 'sober_swag.gemspec'
22
+ - 'example/spec/**/*.rb'
23
+ RSpec/ImplicitBlockExpectation:
24
+ Enabled: false
25
+ RSpec/ImplicitExpect:
26
+ EnforcedStyle: should
27
+ Style/MultilineBlockChain:
28
+ Enabled: false
29
+ Metrics/AbcSize:
30
+ Enabled: false
31
+ Style/Documentation:
32
+ Exclude:
33
+ - 'example/db/migrate/**/*'
34
+ Metrics/PerceivedComplexity:
35
+ Enabled: false
36
+ Layout/EmptyLinesAroundAttributeAccessor:
37
+ Enabled: true
38
+ Layout/SpaceAroundMethodCallOperator:
39
+ Enabled: true
40
+ Lint/DeprecatedOpenSSLConstant:
41
+ Enabled: true
42
+ Lint/DuplicateElsifCondition:
43
+ Enabled: true
44
+ Lint/MixedRegexpCaptureTypes:
45
+ Enabled: true
46
+ Lint/RaiseException:
47
+ Enabled: true
48
+ Lint/StructNewOverride:
49
+ Enabled: true
50
+ Style/AccessorGrouping:
51
+ Enabled: true
52
+ Style/ArrayCoercion:
53
+ Enabled: true
54
+ Style/BisectedAttrAccessor:
55
+ Enabled: true
56
+ Style/CaseLikeIf:
57
+ Enabled: true
58
+ Style/ExponentialNotation:
59
+ Enabled: true
60
+ Style/HashAsLastArrayItem:
61
+ Enabled: true
62
+ Style/HashEachMethods:
63
+ Enabled: true
64
+ Style/HashLikeCase:
65
+ Enabled: true
66
+ Style/HashTransformKeys:
67
+ Enabled: true
68
+ Style/HashTransformValues:
69
+ Enabled: true
70
+ Style/RedundantAssignment:
71
+ Enabled: true
72
+ Style/RedundantFetchBlock:
73
+ Enabled: true
74
+ Style/RedundantFileExtensionInRequire:
75
+ Enabled: true
76
+ Style/RedundantRegexpCharacterClass:
77
+ Enabled: true
78
+ Style/RedundantRegexpEscape:
79
+ Enabled: true
80
+ Style/SlicingWithRange:
81
+ Enabled: true
@@ -1 +1 @@
1
- 2.7.0
1
+ 2.6.5
data/README.md CHANGED
@@ -1,7 +1,160 @@
1
1
  # SoberSwag
2
2
 
3
3
  ![Ruby Test Status](https://github.com/SonderMindOrg/sober_swag/workflows/Ruby/badge.svg?branch=master)
4
+ ![Linters Status](https://github.com/SonderMindOrg/sober_swag/workflows/Linters/badge.svg?branch=master)
4
5
 
5
6
  SoberSwag is a combination of [Dry-Types](https://dry-rb.org/gems/dry-types/1.2/) and [Swagger](https://swagger.io/) that makes your Rails APIs more awesome.
6
- Other tools generate documenation from a DSL.
7
+ Other tools generate documentation from a DSL.
7
8
  This generates documentation from *types*, which (conveniently) also lets you get supercharged strong-params-on-steroids.
9
+
10
+ An introductory presentation is available [here](https://www.icloud.com/keynote/0bxP3Dn8ETNO0lpsSQSVfEL6Q#SoberSwagPresentation).
11
+
12
+ ## Types for a fully-automated API
13
+
14
+ SoberSwag lets you type your API using describe blocks.
15
+ In any controller that includes `SoberSwag::Controller`, you get access to the super-cool DSL method `define`.
16
+ This lets you type your API endpoint:
17
+
18
+ ```ruby
19
+ class PeopleController < ApplicationController
20
+ include SoberSwag::Controller
21
+ define :patch, :update, '/people/{id}' do
22
+ query_params do
23
+ attribute? :include_extra_info, Types::Params::Bool
24
+ end
25
+ request_body do
26
+ attribute? :name, Types::Params::String
27
+ attribute? :age, Types::Params::Integer
28
+ end
29
+ path_params { attribute :id, Types::Params::Integer }
30
+ end
31
+ end
32
+ ```
33
+
34
+ We can now use this information to generate swagger documentation, available at the `swagger` action on this controller.
35
+ More than that, we can use this information *inside* our controller methods:
36
+
37
+ ```ruby
38
+ def update
39
+ @person = Person.find(parsed_path.id)
40
+ @person.update!(parsed_body.to_h)
41
+ end
42
+ ```
43
+
44
+ No need for `params.require` or anything like that.
45
+ You define the type of parameters you accept, and we reject anything that doesn't fit.
46
+
47
+ ### Typed Responses
48
+
49
+ Want to go further and type your responses too?
50
+ Use SoberSwag output objects, a serializer library heavily inspired by [Blueprinter](https://github.com/procore/blueprinter)
51
+
52
+ ```ruby
53
+ PersonOutputObject = SoberSwag::OutputObject.define do
54
+ field :id, primitive(:Integer)
55
+ field :name, primitive(:String).optional
56
+ field :is_registered, primitive(:Bool) do |person|
57
+ person.registered?
58
+ end
59
+ end
60
+ ```
61
+
62
+ Now, in your `define` block, you can tell us that this is the *type* of your response:
63
+
64
+ ```ruby
65
+ class PeopleController < ApplicationController
66
+ include SoberSwag::Controller
67
+ define :patch, :update, '/people/{id}' do
68
+ request_body do
69
+ attribute? :name, Types::Params::String
70
+ attribute? :age, Types::Params::Integer
71
+ end
72
+ path_params { attribute :id, Types::Params::Integer }
73
+ response(:ok, 'the updated person', PersonOutputObject)
74
+ end
75
+ def update
76
+ person = Person.find(parsed_path.id)
77
+ if person.update(parsed_body.to_h)
78
+ respond!(:ok, person)
79
+ else
80
+ render json: person.errors
81
+ end
82
+ end
83
+ end
84
+ ```
85
+
86
+ Support for easily typing "render the activerecord errors for me please" is (unfortunately) under development.
87
+
88
+ ### SoberSwag Input Objects
89
+
90
+ Input parameters (including path, query, and request body) are typed using [dry-struct](https://dry-rb.org/gems/dry-struct/1.0/).
91
+ You don't have to do them inline. You can define them in another file, like so:
92
+
93
+ ```ruby
94
+ User = SoberSwag.input_object do
95
+ attribute :name, SoberSwag::Types::String
96
+ # use ? if attributes are not required
97
+ attribute? :favorite_movie, SoberSwag::Types::String
98
+ # use .optional if attributes may be null
99
+ attribute :age, SoberSwag::Types::Params::Integer.optional
100
+ end
101
+ ```
102
+
103
+ Then, in your controller, just do:
104
+
105
+ ```ruby
106
+ class PeopleController < ApplicationController
107
+ include SoberSwag::Controller
108
+
109
+ define :path, :update, '/people/{id}' do
110
+ request_body(User)
111
+ path_params { attribute :id, Types::Params::Integer }
112
+ response(:ok, 'the updated person', PersonOutputObject)
113
+ end
114
+ def update
115
+ # same as above!
116
+ end
117
+ end
118
+ ```
119
+
120
+ Under the hood, this literally just generates a subclass of `Dry::Struct`.
121
+ We use the DSL-like method just to make working with Rails' reloading less annoying.
122
+
123
+ #### Adding additional documentation
124
+
125
+ You can use the `.meta` attribute on a type to add additional documentation.
126
+ Some keys are considered "well-known" and will be present on the swagger output.
127
+ For example:
128
+
129
+
130
+ ```ruby
131
+ User = SoberSwag.input_object do
132
+ attribute? :name, SoberSwag::Types::String.meta(description: <<~MARKDOWN, deprecated: true)
133
+ The given name of the students, with strings encoded as escaped-ASCII.
134
+ This is used by an internal Cobol microservice from 1968.
135
+ Please use unicode_name instead unless you are that microservice.
136
+ MARKDOWN
137
+ attribute? :unicode_name, SoberSwag::Types::String
138
+ end
139
+ ```
140
+
141
+ This will output the swagger you expect, with a description and a deprecated flag.
142
+
143
+ #### Adding Default Values
144
+
145
+ Sometimes it makes sense to specify a default value.
146
+ Don't worry, we've got you covered:
147
+
148
+ ```ruby
149
+ QueryInput = SoberSwag.input_object do
150
+ attribute :allow_first, SoberSwag::Types::Params::Bool.default(false) # smartly alters type-definition to establish that passing this is not required.
151
+ end
152
+ ```
153
+
154
+ ## Special Thanks
155
+
156
+ This gem is a mishmash of ideas from various sources.
157
+ The biggest thanks is owed to the [dry-rb](https://github.com/dry-rb) project, upon which the typing of SoberSwag is based.
158
+ On an API design level, much is owed to [blueprinter](https://github.com/procore/blueprinter) for the serializers.
159
+ The idea of a strongly-typed API came from the Haskell framework [servant](https://www.servant.dev/).
160
+ Generating the swagger documenation happens via the use of a catamorphism, which I believe I first really understood thanks to [this medium article by Jared Tobin](https://medium.com/@jaredtobin/practical-recursion-schemes-c10648ec1c29).
@@ -1,9 +1,8 @@
1
- #!/usr/bin/env ruby -W:no-experimental
1
+ #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'bundler/setup'
5
5
  require 'sober_swag'
6
- require 'dry-struct'
7
6
 
8
7
  # You can add fixtures and/or initialization code here to make experimenting
9
8
  # with your gem easier. You can also use a different console, if you like.
@@ -11,28 +10,30 @@ module Types
11
10
  include Dry.Types()
12
11
  end
13
12
 
14
- class Bio < Dry::Struct
15
- attribute :name, Types::String
13
+ Bio = SoberSwag.input_object do
14
+ attribute :name, primitive(:String).meta(description: 'A very basic bio name')
16
15
  end
17
16
 
18
- class Person < Dry::Struct
19
- attribute :name, Types::String
20
- attribute :age, Types::Integer.constrained(gt: 0).optional | Types::String.optional
17
+ Person = SoberSwag.input_object do
18
+ attribute :name, primitive(:String).meta(description: 'The full name description')
19
+ attribute :age, param(:Integer).constrained(gt: 0).optional.meta(description: 'My cool age')
21
20
  attribute? :mood, Types::String
22
- attribute :bio, Bio
23
-
24
- attribute :foo do
25
- attribute :bar, Types::String
26
- attribute :baz, Types::String.optional
27
- end
28
21
  end
29
22
 
30
- class PersonSearch < Dry::Struct
23
+ ##
24
+ # Demonstration of subclass-style.
25
+ class PersonSearch < SoberSwag::InputObject
31
26
  attribute? :name, Types::String
32
27
  attribute? :age, Types::Integer
33
28
  end
34
29
 
30
+ ##
31
+ # Demonstration of subclass-style *and* recursion in structs.
32
+ class LinkedList < SoberSwag::InputObject
33
+ attribute :value, Types::String
34
+ attribute? :next, LinkedList
35
+ end
35
36
 
36
37
  # (If you use this, don't forget to add pry to your Gemfile!)
37
- require "pry"
38
+ require 'pry'
38
39
  Pry.start
@@ -0,0 +1,203 @@
1
+ # Serializers
2
+
3
+ Serializers are a way to transform from one type to another.
4
+ For example, you might want to change an ActiveRecord object to a JSON struct.
5
+ You might also want to change an internal date-interval into a two-element array of dates, or some custom text format.
6
+ You can do all of these things with SoberSwag serializers.
7
+ Furthermore, Serializers document the *type* that they serialize, so you can use it to generate documentation.
8
+
9
+ ## The Basics
10
+
11
+ All serializers are inherited from [`SoberSwag::Serializer::Base`](../lib/sober_swag/serializer/base.rb).
12
+ This is an abstract class that implements several methods, most of which will be documented later.
13
+ The two that are most interesting, however, are `#type` and `#serialize`.
14
+
15
+ The first, `#type`, returns a SoberSwag-compatible type definition.
16
+ This might be an instance of `SoberSwag::Struct`, or something else.
17
+ You'll never need to implement this yourself, but you should note that we generally do not *enforce* these types *at serialization time*.
18
+ This might change in the future, likely under a debug flag.
19
+
20
+ The second, `#serialize`, does the actual work of serialization.
21
+ It takes *two arguments*.
22
+ The first is the argument that we will transform into the output type.
23
+ The second is *always* optional, and is a *hash of options* to use to customize serialization.
24
+ For example, you might have a serializer that can return a date in two formats, depending on a boolean flag.
25
+ In this case, it might be used as:
26
+
27
+ ```ruby
28
+ serializer.new(my_record, { format: :newstyle })
29
+ ```
30
+
31
+ However, since it is *always* optional, you can also do:
32
+
33
+ ```ruby
34
+ serilaizer.new(my_record)
35
+ ```
36
+
37
+ And it *should* pick some default format.
38
+
39
+ ### Primitives
40
+
41
+ Primitive serializers, or "identity serializers," are serializers that do nothing.
42
+ They are implemented as [`SoberSwag::Serializer::Primitive`](../lib/sober_swag/serializer/primitive.rb), or as the `#primitive` method on a `OutputObject`.
43
+ Since they don't do anything, they can be considered the most "basic" serializer.
44
+
45
+ These serializers *do not* check types.
46
+ That is, the following code will not throw an error:
47
+
48
+ ```ruby
49
+ serializer = SoberSwag::Serializer::Primitive.new(SoberSwag::Types::String)
50
+ serializer.serialize(10) # => 10
51
+ ```
52
+
53
+ Thus, care should be used when working with these serializers.
54
+ In the future, we might add some "debug mode" sorta thing that will do type-checking and throw errors, however, the cost of doing so in production is probably not worth it.
55
+
56
+ ### Mapped
57
+
58
+ Sometimes, you can create a serializer via a *proc*.
59
+ For example, let's say that I want a serializer that takes a `Date` and returns a string.
60
+ I can do this:
61
+
62
+ ```ruby
63
+ date_string = SoberSwag::Serializer.primitive(:String).via_map { |d| d.to_s }
64
+ ```
65
+
66
+ This is implemented via [`SoberSwag::Serializer::Mapped`](../lib/sober_swag/serializer/mapped.rb).
67
+ Basically, it uses your given proc to do serialization.
68
+
69
+ Once again, this does not do type-checking.
70
+ In the future, we might add a debug mode.
71
+
72
+ ### Optional
73
+
74
+ Oftentimes, we want to give a serializer the ability to serialize `nil` values.
75
+ This is often useful in serializing fields.
76
+
77
+ It turns out that it's pretty easy to make a serializer that can serialize `nil` values: just propogate nils.
78
+ For example, let's say I have the following code:
79
+
80
+ ```ruby
81
+ Foo = Struct.new(:bar, :baz)
82
+ my_serializer.serialize(Foo.new(10, 11)) # => { bar: 10, baz: 11 }
83
+ # ^ my_serializer is defined elsewhere
84
+ my_serializer.optional.serialize(Foo.new(10, 11)) # => { bar: 10, baz: 11 }
85
+ # ^ can serialize the type from before
86
+ my_serializer.optional.serialize(nil) # => nil
87
+ # ^ nils become nil
88
+ ```
89
+
90
+ This properly changes the `type` to be a nilable type, as well.
91
+
92
+ ### Array
93
+
94
+ Oftentimes, if we have a serializer for a single value, we want to serialize an array of values.
95
+ You can use the `#array` method on a serializer to get that.
96
+ Continuing our example from earlier:
97
+
98
+ ```ruby
99
+ my_serializer.array.serialize([Foo.new(10, 11)]) #=> [{ bar: 10, baz: 11 }]
100
+ ```
101
+
102
+ This changes the type properly, too.
103
+
104
+ ## OutputObjects
105
+
106
+ 98% of the time, when we're writing web APIs, we want to transform our domain objects into JSON objects.
107
+ We often want different ways to do this, too.
108
+ Consider, for exmaple, and API for a college.
109
+ We might want to provide one detailed way to serialize a student, which includes their full name, grade, student ID, GPA, and so on.
110
+ On another page, we might want to display a classroom with a list of students.
111
+ However, on the classroom page, we don't want to serialize a full student: that's sending too much data.
112
+ Instead, we probably want to serialize a "stub" view.
113
+
114
+ OutputObjects are the answer to these problems.
115
+ They're a way to define a serializer for a JSON object, along with a type, and to define "variant" ways to serialize things.
116
+
117
+
118
+ ### The Basics
119
+
120
+ Let's define an output object:
121
+
122
+ ```ruby
123
+ StudentOutputObject = SoberSwag::OutputObject.define do
124
+ field :first_name, primitive(:String)
125
+ field :last_name, primitive(:String)
126
+ field :recent_grades, primitive(:Integer).array do |student|
127
+ student.graded_assignments.limit(100).pluck(:grade)
128
+ end
129
+ end
130
+ ```
131
+
132
+ We can see a few things here:
133
+
134
+ 1. You define field names with a `field` definition, which is a way to define the serializer for a single field.
135
+ 2. You must provide types with field names
136
+ 3. You can use blocks to do data formatting, which lets you pick different fields and such.
137
+
138
+ ### Views
139
+
140
+ Sometimes, you might want to add "variant" ways to look at data.
141
+ We call these "views," based on the output objecter concept.
142
+ Let's take a look at their use:
143
+
144
+ ```ruby
145
+ StudentOutputObject = SoberSwag::OutputObject.define do
146
+ field :first_name, primitive(:String)
147
+ field :last_name, primitive(:String)
148
+ view :detail do
149
+ field :recent_grades, primitive(:Integer).array do |student|
150
+ student.graded_assignments.limit(100).pluck(:grade)
151
+ end
152
+ end
153
+ end
154
+
155
+ StudentOutputObject.serialize(my_student) # => { first_name: 'Rich', last_name: 'Evans' }
156
+ StudentOutputObject.serialize(
157
+ my_student,
158
+ { view: :detail }
159
+ ) # => { first_name: 'Rich', last_name: 'Evans', recent_grades: [0, 0, 0, 1] }
160
+ ```
161
+
162
+ The options hash of the serializer will be used to determine which view to serialize with.
163
+ Handily, each view is actually *its own* serializer.
164
+ You can obtain a serializer for a single view very easily:
165
+
166
+ ```ruby
167
+ StudentOutputObject.view(:detail)
168
+ ```
169
+
170
+ If you want an output object without the view-checking behavior, you can use `.base` on an output object.
171
+
172
+ ```ruby
173
+ StudentOutputObject.base
174
+ ```
175
+
176
+ Both of these are great for defining *relationships* between data.
177
+
178
+ ### Circular OutputObjects
179
+
180
+ Sometimes, you might want to include an output object inside another output object, that itself has that output object inside it.
181
+ Or, less confusingly, you wanna do this:
182
+
183
+ ```ruby
184
+ StudentOutputObject = SoberSwag::OutputObject.define do
185
+ # some other fields
186
+ view :detail do
187
+ field :classes, ClassOutputObject.array
188
+ end
189
+ end
190
+ ```
191
+
192
+ This can cause a circular dependency.
193
+ To break this, you can use a lambda:
194
+
195
+ ```ruby
196
+ StudentOutputObject = SoberSwag::OutputObject.define do
197
+ view :detail do
198
+ field :classes, -> { ClassOutputObject.view(:base).array }
199
+ end
200
+ end
201
+ ```
202
+
203
+ For clarity (and to prevent infinitely-looping serializers on accident, we recommend you *always* use an explicit view for dependent output objects.