sober_swag 0.1.0 → 0.2.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 +5 -0
  3. data/.github/workflows/lint.yml +15 -0
  4. data/.github/workflows/ruby.yml +23 -1
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +73 -1
  7. data/.ruby-version +1 -1
  8. data/Gemfile.lock +29 -5
  9. data/README.md +109 -0
  10. data/bin/console +15 -14
  11. data/docs/serializers.md +203 -0
  12. data/example/.rspec +1 -0
  13. data/example/.ruby-version +1 -1
  14. data/example/Gemfile +10 -6
  15. data/example/Gemfile.lock +96 -76
  16. data/example/app/controllers/people_controller.rb +37 -21
  17. data/example/app/controllers/posts_controller.rb +102 -0
  18. data/example/app/models/application_record.rb +3 -0
  19. data/example/app/models/person.rb +6 -0
  20. data/example/app/models/post.rb +9 -0
  21. data/example/app/output_objects/person_errors_output_object.rb +5 -0
  22. data/example/app/output_objects/person_output_object.rb +15 -0
  23. data/example/app/output_objects/post_output_object.rb +10 -0
  24. data/example/bin/bundle +24 -20
  25. data/example/bin/rails +1 -1
  26. data/example/bin/rake +1 -1
  27. data/example/config/application.rb +11 -7
  28. data/example/config/environments/development.rb +0 -1
  29. data/example/config/environments/production.rb +3 -3
  30. data/example/config/puma.rb +5 -5
  31. data/example/config/routes.rb +3 -0
  32. data/example/config/spring.rb +4 -4
  33. data/example/db/migrate/20200311152021_create_people.rb +0 -1
  34. data/example/db/migrate/20200603172347_create_posts.rb +11 -0
  35. data/example/db/schema.rb +16 -7
  36. data/example/spec/rails_helper.rb +64 -0
  37. data/example/spec/requests/people/create_spec.rb +52 -0
  38. data/example/spec/requests/people/get_spec.rb +35 -0
  39. data/example/spec/requests/people/index_spec.rb +69 -0
  40. data/example/spec/spec_helper.rb +94 -0
  41. data/lib/sober_swag.rb +6 -3
  42. data/lib/sober_swag/compiler/error.rb +2 -0
  43. data/lib/sober_swag/compiler/path.rb +2 -5
  44. data/lib/sober_swag/compiler/paths.rb +0 -1
  45. data/lib/sober_swag/compiler/type.rb +28 -15
  46. data/lib/sober_swag/controller.rb +16 -11
  47. data/lib/sober_swag/controller/route.rb +18 -21
  48. data/lib/sober_swag/controller/undefined_body_error.rb +3 -0
  49. data/lib/sober_swag/controller/undefined_path_error.rb +3 -0
  50. data/lib/sober_swag/controller/undefined_query_error.rb +3 -0
  51. data/lib/sober_swag/input_object.rb +28 -0
  52. data/lib/sober_swag/nodes/array.rb +1 -1
  53. data/lib/sober_swag/nodes/base.rb +2 -4
  54. data/lib/sober_swag/nodes/binary.rb +2 -1
  55. data/lib/sober_swag/nodes/enum.rb +4 -2
  56. data/lib/sober_swag/nodes/list.rb +0 -1
  57. data/lib/sober_swag/nodes/primitive.rb +6 -5
  58. data/lib/sober_swag/output_object.rb +102 -0
  59. data/lib/sober_swag/output_object/definition.rb +30 -0
  60. data/lib/sober_swag/{blueprint → output_object}/field.rb +14 -4
  61. data/lib/sober_swag/{blueprint → output_object}/field_syntax.rb +1 -1
  62. data/lib/sober_swag/{blueprint → output_object}/view.rb +15 -6
  63. data/lib/sober_swag/parser.rb +5 -3
  64. data/lib/sober_swag/serializer.rb +5 -2
  65. data/lib/sober_swag/serializer/array.rb +12 -0
  66. data/lib/sober_swag/serializer/base.rb +50 -1
  67. data/lib/sober_swag/serializer/conditional.rb +15 -2
  68. data/lib/sober_swag/serializer/field_list.rb +29 -6
  69. data/lib/sober_swag/serializer/mapped.rb +12 -2
  70. data/lib/sober_swag/serializer/meta.rb +35 -0
  71. data/lib/sober_swag/serializer/optional.rb +17 -2
  72. data/lib/sober_swag/serializer/primitive.rb +4 -1
  73. data/lib/sober_swag/server.rb +83 -0
  74. data/lib/sober_swag/types.rb +3 -0
  75. data/lib/sober_swag/version.rb +1 -1
  76. data/sober_swag.gemspec +6 -4
  77. metadata +77 -44
  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
@@ -1,14 +1,16 @@
1
1
  module SoberSwag
2
- class Blueprint
2
+ class OutputObject
3
+ ##
4
+ # DSL for defining a view.
5
+ # Used in `view` blocks within {SoberSwag::OutputObject.define}.
3
6
  class View
4
-
5
7
  def self.define(name, base_fields, &block)
6
- self.new(name, base_fields).tap do |view|
8
+ new(name, base_fields).tap do |view|
7
9
  view.instance_eval(&block)
8
10
  end
9
11
  end
10
12
 
11
- class NestingError < Error; end;
13
+ class NestingError < Error; end
12
14
 
13
15
  include FieldSyntax
14
16
 
@@ -19,8 +21,16 @@ module SoberSwag
19
21
 
20
22
  attr_reader :name, :fields
21
23
 
24
+ def serialize(obj, opts = {})
25
+ serializer.serialize(obj, opts)
26
+ end
27
+
28
+ def type
29
+ serializer.type
30
+ end
31
+
22
32
  def except!(name)
23
- @fields.select! { |f| f.name != name }
33
+ @fields.reject! { |f| f.name == name }
24
34
  end
25
35
 
26
36
  def view(*)
@@ -38,7 +48,6 @@ module SoberSwag
38
48
  @serializer ||=
39
49
  SoberSwag::Serializer::FieldList.new(fields)
40
50
  end
41
-
42
51
  end
43
52
  end
44
53
  end
@@ -1,11 +1,14 @@
1
1
  module SoberSwag
2
+ ##
3
+ # Parses a *Dry-Types Schema* into a set of nodes we can use to compile.
4
+ # This is mostly because the vistior pattern sucks and catamorphisms are nice.
2
5
  class Parser
3
6
  def initialize(node)
4
7
  @node = node
5
8
  @found = Set.new
6
9
  end
7
10
 
8
- def to_syntax
11
+ def to_syntax # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
9
12
  case @node
10
13
  when Dry::Types::Array::Member
11
14
  Nodes::List.new(bind(Parser.new(@node.member)))
@@ -41,7 +44,7 @@ module SoberSwag
41
44
  bind(Parser.new(@node.type))
42
45
  when Dry::Types::Nominal
43
46
  # start off with the moral equivalent of NodeTree[String]
44
- Nodes::Primitive.new(@node.primitive)
47
+ Nodes::Primitive.new(@node.primitive, @node.meta)
45
48
  else
46
49
  # Inside of this case we have a class that is some user-defined type
47
50
  # We put it in our array of found types, and consider it a primitive
@@ -68,6 +71,5 @@ module SoberSwag
68
71
  @found += other.found
69
72
  result
70
73
  end
71
-
72
74
  end
73
75
  end
@@ -1,4 +1,7 @@
1
1
  module SoberSwag
2
+ ##
3
+ # Container module for serializers.
4
+ # The interface for these is described in {SoberSwag::Serializer::Base}.
2
5
  module Serializer
3
6
  autoload(:Base, 'sober_swag/serializer/base')
4
7
  autoload(:Primitive, 'sober_swag/serializer/primitive')
@@ -7,6 +10,7 @@ module SoberSwag
7
10
  autoload(:Mapped, 'sober_swag/serializer/mapped')
8
11
  autoload(:Optional, 'sober_swag/serializer/optional')
9
12
  autoload(:FieldList, 'sober_swag/serializer/field_list')
13
+ autoload(:Meta, 'sober_swag/serializer/meta')
10
14
 
11
15
  class << self
12
16
  ##
@@ -14,10 +18,9 @@ module SoberSwag
14
18
  # in values raw.
15
19
  #
16
20
  # @param contained {Class} Dry::Type to use
17
- def Primitive(contained)
21
+ def Primitive(contained) # rubocop:disable Naming/MethodName
18
22
  SoberSwag::Serializer::Primitive.new(contained)
19
23
  end
20
24
  end
21
-
22
25
  end
23
26
  end
@@ -7,6 +7,18 @@ module SoberSwag
7
7
  @element_serializer = element_serializer
8
8
  end
9
9
 
10
+ def lazy_type?
11
+ @element_serializer.lazy_type?
12
+ end
13
+
14
+ def lazy_type
15
+ SoberSwag::Types::Array.of(@element_serializer.lazy_type)
16
+ end
17
+
18
+ def finalize_lazy_type!
19
+ @element_serializer.finalize_lazy_type!
20
+ end
21
+
10
22
  attr_reader :element_serializer
11
23
 
12
24
  def serialize(object, options = {})
@@ -1,7 +1,10 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
+ ##
4
+ # Base interface class that all other serializers are subclasses of.
5
+ # This also defines methods as stubs, which is sort of bad ruby style, but makes documentation
6
+ # easier to generate.
3
7
  class Base
4
-
5
8
  ##
6
9
  # Return a new serializer that is an *array* of elements of this serializer.
7
10
  def array
@@ -14,6 +17,46 @@ module SoberSwag
14
17
  SoberSwag::Serializer::Optional.new(self)
15
18
  end
16
19
 
20
+ ##
21
+ # Is this type lazily defined?
22
+ #
23
+ # Used for mutual recursion.
24
+ def lazy_type?
25
+ false
26
+ end
27
+
28
+ ##
29
+ # The lazy version of this type, for mutual recursion.
30
+ def lazy_type
31
+ type
32
+ end
33
+
34
+ ##
35
+ # Finalize a lazy type.
36
+ #
37
+ # Should be idempotent: call it once, and it does nothing on subsequent calls (but returns the type).
38
+ def finalize_lazy_type!
39
+ type
40
+ end
41
+
42
+ ##
43
+ # Serialize an object.
44
+ def serialize(_object, _options = {})
45
+ raise ArgumentError, 'not implemented!'
46
+ end
47
+
48
+ ##
49
+ # Get the type that we serialize to.
50
+ def type
51
+ raise ArgumentError, 'not implemented!'
52
+ end
53
+
54
+ ##
55
+ # Add metadata onto the *type* of a serializer.
56
+ def meta(hash)
57
+ SoberSwag::Serializer::Meta.new(self, hash)
58
+ end
59
+
17
60
  ##
18
61
  # If I am a serializer for type 'a', and you give me a way to turn 'a's into 'b's,
19
62
  # I can give you a serializer for type 'b' by running the funciton you gave.
@@ -33,6 +76,12 @@ module SoberSwag
33
76
  self
34
77
  end
35
78
 
79
+ ##
80
+ # Get the type name of this to be used externally, or set it if an argument is provided
81
+ def identifier(arg = nil)
82
+ @identifier = arg if arg
83
+ @identifier
84
+ end
36
85
  end
37
86
  end
38
87
  end
@@ -25,9 +25,10 @@ module SoberSwag
25
25
 
26
26
  def serialize(object, options = {})
27
27
  tag, val = chooser.call(object, options)
28
- if tag == :left
28
+ case tag
29
+ when :left
29
30
  left.serialize(val, options)
30
- elsif tag == :right
31
+ when :right
31
32
  right.serialize(val, options)
32
33
  else
33
34
  raise BadChoiceError, "result of chooser proc was not a left or right, but a #{val.class}"
@@ -44,6 +45,18 @@ module SoberSwag
44
45
  left.type | right.type
45
46
  end
46
47
  end
48
+
49
+ def lazy_type
50
+ left.lazy_type | right.lazy_type
51
+ end
52
+
53
+ def lazy_type?
54
+ left.lazy_type? || right.lazy_type?
55
+ end
56
+
57
+ def finalize_lazy_type!
58
+ [left, right].each(&:finalize_lazy_type!)
59
+ end
47
60
  end
48
61
  end
49
62
  end
@@ -4,7 +4,6 @@ module SoberSwag
4
4
  # Extract out a hash from a list of
5
5
  # name/serializer pairs.
6
6
  class FieldList < Base
7
-
8
7
  def initialize(field_list)
9
8
  @field_list = field_list
10
9
  end
@@ -17,8 +16,7 @@ module SoberSwag
17
16
  SoberSwag::Serializer.Primitive(SoberSwag::Types.const_get(symbol))
18
17
  end
19
18
 
20
-
21
- def serialize(object, options)
19
+ def serialize(object, options = {})
22
20
  field_list.map { |field|
23
21
  [field.name, field.serializer.serialize(object, options)]
24
22
  }.to_h
@@ -28,17 +26,42 @@ module SoberSwag
28
26
  @type ||= make_struct_type!
29
27
  end
30
28
 
29
+ def lazy_type?
30
+ true
31
+ end
32
+
33
+ def lazy_type
34
+ struct_class
35
+ end
36
+
37
+ def finalize_lazy_type!
38
+ make_struct_type!
39
+ end
40
+
31
41
  private
32
42
 
33
- def make_struct_type!
43
+ def make_struct_type! # rubocop:disable Metrics/MethodLength
44
+ # mutual recursion makes this really, really annoying.
45
+ return struct_class if @made_struct_type
46
+
34
47
  f = field_list
35
- Class.new(Dry::Struct) do
48
+ s = identifier
49
+ struct_class.instance_eval do
50
+ identifier(s)
36
51
  f.each do |field|
37
- attribute field.name, field.serializer.type
52
+ attribute field.name, field.serializer.lazy_type
38
53
  end
39
54
  end
55
+ @made_struct_type = true
56
+
57
+ field_list.map(&:serializer).each(&:finalize_lazy_type!)
58
+
59
+ struct_class
40
60
  end
41
61
 
62
+ def struct_class
63
+ @struct_class ||= Class.new(SoberSwag::InputObject)
64
+ end
42
65
  end
43
66
  end
44
67
  end
@@ -3,7 +3,6 @@ module SoberSwag
3
3
  ##
4
4
  # A new serializer by mapping over the serialization function
5
5
  class Mapped < Base
6
-
7
6
  def initialize(base, map_f)
8
7
  @base = base
9
8
  @map_f = map_f
@@ -13,6 +12,18 @@ module SoberSwag
13
12
  @base.serialize(@map_f.call(object), options)
14
13
  end
15
14
 
15
+ def lazy_type?
16
+ @base.lazy_type?
17
+ end
18
+
19
+ def lazy_type
20
+ @base.lazy_type
21
+ end
22
+
23
+ def finalize_lazy_type!
24
+ @base.finalize_lazy_type!
25
+ end
26
+
16
27
  def type
17
28
  @base.type
18
29
  end
@@ -23,7 +34,6 @@ module SoberSwag
23
34
  def via_map(&block)
24
35
  SoberSwag::Serializer::Mapped.new(@base, @map_f >> block)
25
36
  end
26
-
27
37
  end
28
38
  end
29
39
  end
@@ -0,0 +1,35 @@
1
+ module SoberSwag
2
+ module Serializer
3
+ ##
4
+ # Provides metadata on a serializer.
5
+ # All actions delegate to the base.
6
+ class Meta < Base
7
+ def initialize(base, meta)
8
+ @base = base
9
+ @meta = meta
10
+ end
11
+
12
+ attr_reader :base, :meta
13
+
14
+ def serialize(args, opts = {})
15
+ base.serialize(args, opts)
16
+ end
17
+
18
+ def lazy_type
19
+ @base.lazy_type.meta(**meta)
20
+ end
21
+
22
+ def type
23
+ @base.type.meta(**meta)
24
+ end
25
+
26
+ def finalize_lazy_type!
27
+ @base.finalize_lazy_type!
28
+ end
29
+
30
+ def lazy_type?
31
+ @base.lazy_type?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,13 +1,29 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
+ ##
4
+ # Given something that serializes a type 'A',
5
+ # this can be used to make a serializer of type 'A | nil'.
6
+ #
7
+ # Or, put another way, makes serializers not crash on nil values.
3
8
  class Optional < Base
4
-
5
9
  def initialize(inner)
6
10
  @inner = inner
7
11
  end
8
12
 
9
13
  attr_reader :inner
10
14
 
15
+ def lazy_type?
16
+ @inner.lazy_type?
17
+ end
18
+
19
+ def lazy_type
20
+ @inner.lazy_type.optional
21
+ end
22
+
23
+ def finalize_lazy_type!
24
+ @inner.finalize_lazy_type!
25
+ end
26
+
11
27
  def serialize(object, options = {})
12
28
  if object.nil?
13
29
  object
@@ -23,7 +39,6 @@ module SoberSwag
23
39
  def optional(*)
24
40
  raise ArgumentError, 'no nesting optionals please'
25
41
  end
26
-
27
42
  end
28
43
  end
29
44
  end
@@ -1,5 +1,8 @@
1
1
  module SoberSwag
2
2
  module Serializer
3
+ ##
4
+ # A class that does *no* serialization: you give it a type,
5
+ # and it will pass any serialized input on verbatim.
3
6
  class Primitive < Base
4
7
  def initialize(type)
5
8
  @type = type
@@ -7,7 +10,7 @@ module SoberSwag
7
10
 
8
11
  attr_reader :type
9
12
 
10
- def serialize(object, options = {})
13
+ def serialize(object, _options = {})
11
14
  object
12
15
  end
13
16
  end
@@ -0,0 +1,83 @@
1
+ require 'set'
2
+
3
+ module SoberSwag
4
+ ##
5
+ # A basic, rack-only server to serve up swagger definitions.
6
+ # By default it is configured to work with rails, but you can pass stuff to initialize.
7
+ class Server
8
+ RAILS_CONTROLLER_PROC = proc do
9
+ Rails.application.routes.routes.map { |route|
10
+ route.defaults[:controller]
11
+ }.to_set.reject(&:nil?).map { |controller|
12
+ "#{controller}_controller".classify.constantize
13
+ }.filter { |controller| controller.ancestors.include?(SoberSwag::Controller) }
14
+ end
15
+
16
+ ##
17
+ # Start up.
18
+ #
19
+ # @param controller_proc [Proc] a proc that, when called, gives a list of {SoberSwag::Controller}s to document
20
+ # @param cache [Bool | Proc] if we should cache our defintions (default false)
21
+ def initialize(
22
+ controller_proc: RAILS_CONTROLLER_PROC,
23
+ cache: false
24
+ )
25
+ @controller_proc = controller_proc
26
+ @cache = cache
27
+ end
28
+
29
+ EFFECT_HTML = <<~HTML.freeze
30
+ <!DOCTYPE html>
31
+ <html>
32
+ <head>
33
+ <title>Swagger-UI</title>
34
+ <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
35
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@3.23.4/swagger-ui.css"></link>
36
+ </head>
37
+ <body>
38
+ <div id="swagger">
39
+ </div>
40
+ <script>
41
+ SwaggerUIBundle({url: 'SCRIPT_NAME', dom_id: '#swagger'})
42
+ </script>
43
+ </body>
44
+ </html>
45
+ HTML
46
+
47
+ def call(env)
48
+ req = Rack::Request.new(env)
49
+ if req.path_info&.match?(/json/si) || req.get_header('Accept')&.match?(/json/si)
50
+ [200, { 'Content-Type' => 'application/json' }, [generate_json_string]]
51
+ else
52
+ [200, { 'Content-Type' => 'text/html' }, [EFFECT_HTML.gsub(/SCRIPT_NAME/, env['SCRIPT_NAME'] + '.json')]]
53
+ end
54
+ end
55
+
56
+ def generate_json_string
57
+ if cache?
58
+ @json_string ||= JSON.dump(generate_swagger)
59
+ else
60
+ JSON.dump(generate_swagger)
61
+ end
62
+ end
63
+
64
+ def cache?
65
+ @cache.respond_to?(:call) ? @cache.call : @cache
66
+ end
67
+
68
+ def generate_swagger
69
+ routes = sober_controllers.flat_map(&:defined_routes).reduce(SoberSwag::Compiler.new) { |c, r| c.add_route(r) }
70
+ {
71
+ openapi: '3.0.0',
72
+ info: {
73
+ version: '1',
74
+ title: 'SoberSwag Swagger'
75
+ }
76
+ }.merge(routes.to_swagger)
77
+ end
78
+
79
+ def sober_controllers
80
+ @controller_proc.call
81
+ end
82
+ end
83
+ end