explicit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ <h1 id="<%= page.anchor %>"><%= page.title %></h1>
2
+
3
+ <div class="page__container">
4
+ <div class="page__request">
5
+ <div class="page__request__description markdown">
6
+ <%= page.description_html %>
7
+ </div>
8
+
9
+ <div class="params">
10
+ <div class="params__header">
11
+ <%= t("explicit.documentation.params_header") %>
12
+ </div>
13
+
14
+ <% page.params_properties.each do |property| %>
15
+ <div class="params__param">
16
+ <div class="params__param__name">
17
+ <%= property.name %>
18
+ </div>
19
+
20
+ <% if (description_html = property.description_html) %>
21
+ <div class="params__param__description markdown">
22
+ <%= description_html %>
23
+ </div>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="page__response">
31
+ <% page.request.responses.map { |k, v| k }.sort.each do |status| %>
32
+ <div>
33
+ <%= status %>
34
+ </div>
35
+ <% end %>
36
+ </div>
37
+ </div>
@@ -3,6 +3,7 @@ en:
3
3
  errors:
4
4
  agreement: "must be accepted"
5
5
  array: "invalid item at index(%{index}): %{error}"
6
+ bigdecimal: "must be a string-encoded decimal number"
6
7
  boolean: "must be a boolean"
7
8
  date_time_iso8601: "must be a valid iso8601 date time"
8
9
  date_time_posix: "must be a valid posix timestamps"
@@ -21,3 +22,5 @@ en:
21
22
  minlength: "length must be greater than or equal to %{minlength}"
22
23
  maxlength: "length must be smaller than or equal to %{maxlength}"
23
24
  format: "must have format %{regex}"
25
+ documentation:
26
+ params_header: Request params
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Explicit
4
+ extend self
5
+
6
+ class Configuration
7
+ def initialize
8
+ @rescue_from_invalid_params = true
9
+ end
10
+
11
+ def request_examples_file_path=(path)
12
+ @request_examples_file_path = path
13
+ end
14
+
15
+ def request_examples_file_path
16
+ @request_examples_file_path ||= ::Rails.root&.join("public/explicit_request_examples.json")
17
+ end
18
+
19
+ def request_examples_persistance_enabled?
20
+ ENV["EXPLICIT_PERSIST_EXAMPLES"].in? %w[true 1 on]
21
+ end
22
+
23
+ def rescue_from_invalid_params=(enabled)
24
+ @rescue_from_invalid_params = enabled
25
+ end
26
+
27
+ def rescue_from_invalid_params?
28
+ @rescue_from_invalid_params
29
+ end
30
+
31
+ def test_runner=(test_runner)
32
+ @test_runner = test_runner
33
+ end
34
+
35
+ def test_runner
36
+ @test_runner ||=
37
+ if defined?(::RSpec) && ::RSpec.respond_to?(:configure)
38
+ :rspec
39
+ else
40
+ :minitest
41
+ end
42
+ end
43
+ end
44
+
45
+ attr_reader :configuration
46
+ @configuration = Configuration.new
47
+
48
+ def configure(&block)
49
+ block.call(@configuration)
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Explicit::Documentation::Markdown
4
+ extend self
5
+
6
+ def render(markdown_text)
7
+ offset = 0
8
+ markdown_text.each_char do |ch|
9
+ break if ch != " "
10
+
11
+ offset += 1
12
+ end
13
+
14
+ markdown_text = markdown_text.each_line.map do |line|
15
+ line[offset..-1] || "\n"
16
+ end
17
+
18
+ ::Commonmarker.to_html(markdown_text.join, options: {
19
+ parse: { smart: true },
20
+ render: { hardbreaks: false }
21
+ }).html_safe
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Explicit::Documentation::Property
4
+ attr_reader :name, :spec
5
+
6
+ def initialize(name:, spec:)
7
+ @name = name
8
+ @spec = spec
9
+ end
10
+
11
+ def description_html
12
+ case spec
13
+ in [:description, markdown_text, _subspec]
14
+ Explicit::Documentation::Markdown.render(markdown_text).html_safe
15
+ else
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -1,33 +1,151 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "commonmarker"
4
+
3
5
  module Explicit::Documentation
6
+ Section = ::Data.define(:name, :pages)
7
+
8
+ module Page
9
+ class Partial
10
+ attr_reader :title, :partial
11
+
12
+ def initialize(title:, partial:)
13
+ @title = title
14
+ @partial = partial
15
+ end
16
+
17
+ def request?
18
+ false
19
+ end
20
+
21
+ def anchor
22
+ title.dasherize
23
+ end
24
+ end
25
+
26
+ class Request
27
+ attr_reader :request
28
+
29
+ def initialize(request:)
30
+ @request = request
31
+ end
32
+
33
+ def request?
34
+ true
35
+ end
36
+
37
+ def title
38
+ @request.get_title
39
+ end
40
+
41
+ def description_html
42
+ Explicit::Documentation::Markdown.render(@request.get_description).html_safe
43
+ end
44
+
45
+ def anchor
46
+ title.dasherize
47
+ end
48
+
49
+ def params_properties
50
+ @request.params.map do |name, spec|
51
+ Property.new(name:, spec:)
52
+ end
53
+ end
54
+
55
+ def headers_properties
56
+ @request.headers.map do |name, spec|
57
+ Property.new(name:, spec:)
58
+ end
59
+ end
60
+
61
+ def partial
62
+ "request"
63
+ end
64
+ end
65
+ end
66
+
4
67
  class Builder
68
+ attr_reader :sections
69
+
70
+ def initialize
71
+ @sections = []
72
+ @current_section = nil
73
+ end
74
+
5
75
  def page_title(page_title)
6
76
  @page_title = page_title
7
77
  end
8
78
 
9
- def primary_color(primary_color)
10
- @primary_color = primary_color
11
- end
79
+ def section(name, &block)
80
+ @current_section = Section.new(name:, pages: [])
81
+
82
+ block.call
83
+
84
+ @sections << @current_section
12
85
 
13
- def section(name)
86
+ @current_section = nil
14
87
  end
15
88
 
16
- def add(**opts)
89
+ def add(*requests, **opts)
90
+ raise ArgumentError(<<-MD) if @current_section.nil?
91
+ You must define a section before adding a page. For example:
92
+
93
+ section "Customers" do
94
+ add CustomersController::CreateRequest
95
+ end
96
+ MD
97
+
98
+ if requests.one?
99
+ @current_section.pages << Page::Request.new(request: requests.first)
100
+ elsif opts[:partial]
101
+ @current_section.pages << Page::Partial.new(title: opts[:title], partial: opts[:partial])
102
+ else
103
+ raise ArgumentError("expected request or a partial")
104
+ end
17
105
  end
18
106
 
19
107
  def call(request)
20
- html = Explicit::ApplicationController.render(
21
- partial: "documentation",
22
- locals: {
23
- page_title: @page_title,
24
- primary_color: @primary_color,
25
- sections: @sections
26
- }
27
- )
108
+ @html ||= render_documentation_page
28
109
 
29
- [200, {}, [html]]
110
+ [200, {}, [@html]]
30
111
  end
112
+
113
+ private
114
+ def render_documentation_page
115
+ merge_request_examples_from_file!
116
+
117
+ Explicit::ApplicationController.render(
118
+ partial: "documentation",
119
+ locals: {
120
+ page_title: @page_title,
121
+ sections: @sections
122
+ }
123
+ )
124
+ end
125
+
126
+ def merge_request_examples_from_file!
127
+ return if !Explicit.configuration.request_examples_file_path
128
+
129
+ encoded = ::File.read(Explicit.configuration.request_examples_file_path)
130
+ examples = ::JSON.parse(encoded)
131
+
132
+ @sections.each do |section|
133
+ section.pages.filter(&:request?).each do |page|
134
+ if examples.key?(page.request.gid)
135
+ examples[page.request.gid].each do |example|
136
+ page.request.add_example(
137
+ params: example["params"].with_indifferent_access,
138
+ headers: example["headers"],
139
+ response: {
140
+ status: example.dig("response", "status"),
141
+ data: example.dig("response", "data").with_indifferent_access
142
+ }
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
31
149
  end
32
150
 
33
151
  def self.build(&block)
@@ -2,8 +2,10 @@
2
2
 
3
3
  class Explicit::Engine < ::Rails::Engine
4
4
  initializer "explicit.rescue_from_invalid_params" do
5
- ActiveSupport.on_load(:action_controller_api) do
6
- include Explicit::Request::InvalidParams::Handler
5
+ if Explicit.configuration.rescue_from_invalid_params?
6
+ ActiveSupport.on_load(:action_controller_api) do
7
+ include Explicit::Request::InvalidParamsError::Rescuer
8
+ end
7
9
  end
8
10
  end
9
11
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Explicit::Request
4
+ Example = ::Data.define(:params, :headers, :response)
5
+ end
@@ -1,19 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Explicit::Request::InvalidParams
4
- class Error < ::RuntimeError
5
- attr_reader :errors
3
+ class Explicit::Request::InvalidParamsError < ::RuntimeError
4
+ attr_reader :errors
6
5
 
7
- def initialize(errors)
8
- @errors = errors
9
- end
6
+ def initialize(errors)
7
+ @errors = errors
10
8
  end
11
9
 
12
- module Handler
10
+ module Rescuer
13
11
  extend ::ActiveSupport::Concern
14
12
 
15
13
  included do
16
- rescue_from Error do |err|
14
+ rescue_from Explicit::Request::InvalidParamsError do |err|
17
15
  params = Explicit::Spec::Error.translate(err.errors)
18
16
 
19
17
  render json: { error: "invalid_params", params: }, status: 422
@@ -2,16 +2,30 @@
2
2
 
3
3
  class Explicit::Request::InvalidResponseError < ::RuntimeError
4
4
  def initialize(response, error)
5
+ error => [:one_of, *errs]
6
+ error = errs.map { translate_response_error(response, _1) }.join("\n")
7
+
8
+
5
9
  super <<-TXT
6
- Response did not match expected spec.
7
10
 
8
- Got:
9
11
 
10
- #{response.inspect}
12
+ Got:
13
+
14
+ HTTP #{response.status} #{JSON.pretty_generate(response.data)}
11
15
 
12
- Expected:
16
+ This response doesn't match any spec. Here are the errors:
17
+
18
+ #{error.presence || " ==> no response specs found for status #{response.status}"}
13
19
 
14
- #{error.inspect}
15
20
  TXT
16
21
  end
22
+
23
+ private
24
+ def translate_response_error(response, error)
25
+ error = Explicit::Spec::Error.translate(error)
26
+
27
+ <<-TXT
28
+ HTTP #{response.status} #{JSON.pretty_generate(error)}
29
+ TXT
30
+ end
17
31
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Explicit::Request
4
+ Response = ::Data.define(:status, :data) do
5
+ def dig(...) = data.dig(...)
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Explicit::Request
4
+ Route = ::Data.define(:method, :path) do
5
+ def to_s
6
+ "#{method.to_s.upcase} #{path}"
7
+ end
8
+ end
9
+ end
@@ -1,73 +1,124 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Explicit::Request
4
- Route = ::Data.define(:method, :path)
5
-
6
- class << self
7
- def get(path) = routes << Route.new(method: :get, path:)
8
- def head(path) = routes << Route.new(method: :head, path:)
9
- def post(path) = routes << Route.new(method: :post, path:)
10
- def put(path) = routes << Route.new(method: :put, path:)
11
- def delete(path) = routes << Route.new(method: :delete, path:)
12
- def options(path) = routes << Route.new(method: :options, path:)
13
- def patch(path) = routes << Route.new(method: :patch, path:)
14
-
15
- def title(text)
16
- # TODO
4
+ attr_reader :routes, :headers, :params, :responses, :examples
5
+
6
+ def initialize(&block)
7
+ @routes = []
8
+ @headers = {}
9
+ @params = {}
10
+ @responses = Hash.new { |hash, key| hash[key] = [] }
11
+ @examples = Hash.new { |hash, key| hash[key] = [] }
12
+
13
+ if Explicit.configuration.rescue_from_invalid_params?
14
+ @responses[422] << {
15
+ error: "invalid_params",
16
+ params: [:hash, :string, :string]
17
+ }
17
18
  end
18
19
 
19
- def description(markdown)
20
- # TODO
21
- end
20
+ instance_eval(&block)
21
+ end
22
+
23
+ def new(&block)
24
+ subrequest = self.class.new { }
25
+
26
+ subrequest.instance_variable_set(:@base_url, @base_url)
27
+ subrequest.instance_variable_set(:@base_path, @base_path)
28
+ subrequest.instance_variable_set(:@routes, @routes.dup)
29
+ subrequest.instance_variable_set(:@headers, @headers.dup)
30
+ subrequest.instance_variable_set(:@params, @params.dup)
31
+ subrequest.instance_variable_set(:@responses, @responses.dup)
32
+ subrequest.instance_variable_set(:@examples, @examples.dup)
33
+
34
+ subrequest.tap { _1.instance_eval(&block) }
35
+ end
36
+
37
+ def get(path) = @routes << Route.new(method: :get, path:)
38
+ def head(path) = @routes << Route.new(method: :head, path:)
39
+ def post(path) = @routes << Route.new(method: :post, path:)
40
+ def put(path) = @routes << Route.new(method: :put, path:)
41
+ def delete(path) = @routes << Route.new(method: :delete, path:)
42
+ def options(path) = @routes << Route.new(method: :options, path:)
43
+ def patch(path) = @routes << Route.new(method: :patch, path:)
44
+
45
+ def base_url(url) = (@base_url = url)
46
+ def get_base_url = @base_url
22
47
 
23
- def header(name, format)
24
- raise ArgumentError("duplicated header #{name}") if headers.key?(name)
48
+ def base_path(prefix) = (@base_path = prefix)
49
+ def get_base_path = @base_path
25
50
 
26
- headers[name] = format
51
+ def title(text) = (@title = text)
52
+ def get_title = @title || @routes.first.to_s
53
+
54
+ def description(markdown) = (@description = markdown)
55
+ def get_description = @description
56
+
57
+ def header(name, spec)
58
+ raise ArgumentError("duplicated header #{name}") if @headers.key?(name)
59
+
60
+ @headers[name] = spec
61
+ end
62
+
63
+ def param(name, spec, **options)
64
+ raise ArgumentError("duplicated param #{name}") if @params.key?(name)
65
+
66
+ if options[:optional]
67
+ spec = [:nilable, spec]
27
68
  end
28
69
 
29
- def param(name, format, **options)
30
- raise ArgumentError("duplicated param #{name}") if params.key?(name)
70
+ if (defaultval = options[:default])
71
+ spec = [:default, defaultval, spec]
72
+ end
31
73
 
32
- params[name] = format
74
+ if (description = options[:description])
75
+ spec = [:description, description, spec]
33
76
  end
34
77
 
35
- def response(status, format)
36
- responses << { status: [:literal, status], data: format }
78
+ @params[name] = spec
79
+ end
80
+
81
+ def response(status, spec)
82
+ @responses[status] << spec
83
+ end
84
+
85
+ def add_example(params:, response:, headers: {})
86
+ raise ArgumentError.new("missing :status in response") if !response.key?(:status)
87
+ raise ArgumentError.new("missing :data in response") if !response.key?(:data)
88
+
89
+ status, data = response.values_at(:status, :data)
90
+
91
+ response = Response.new(status:, data:)
92
+
93
+ case responses_validator(status:).call(data)
94
+ in [:ok, _] then nil
95
+ in [:error, err] then raise InvalidResponseError.new(response, err)
37
96
  end
38
97
 
39
- def validate!(values)
40
- params_validator = Explicit::Spec.build(params)
98
+ @examples[response.status] << Example.new(params:, headers:, response:)
99
+ end
41
100
 
42
- case params_validator.call(values)
43
- in [:ok, validated_data] then validated_data
44
- in [:error, err] then raise InvalidParams::Error.new(err)
45
- end
101
+ def validate!(values)
102
+ case params_validator.call(values)
103
+ in [:ok, validated_data] then validated_data
104
+ in [:error, err] then raise InvalidParamsError.new(err)
46
105
  end
106
+ end
47
107
 
48
- private
49
- def routes
50
- @routes ||= []
51
- end
52
-
53
- def headers
54
- @headers ||= {}
55
- end
56
-
57
- def params
58
- @params ||= {}
59
- end
60
-
61
- INVALID_PARAMS_SPEC = {
62
- status: [:literal, 422],
63
- data: {
64
- error: "invalid_params",
65
- params: [:hash, :string, :string]
66
- }
67
- }.freeze
68
-
69
- def responses
70
- @responses ||= [INVALID_PARAMS_SPEC]
71
- end
108
+ def gid
109
+ routes.first.to_s
72
110
  end
111
+
112
+ private
113
+ def params_validator
114
+ @params_validator ||= Explicit::Spec.build(@params)
115
+ end
116
+
117
+ def headers_validator
118
+ @headers_validator ||= Explicit::Spec.build(@headers)
119
+ end
120
+
121
+ def responses_validator(status:)
122
+ Explicit::Spec.build([:one_of, *@responses[status]])
123
+ end
73
124
  end
@@ -3,26 +3,15 @@
3
3
  module Explicit::Spec::Agreement
4
4
  extend self
5
5
 
6
- VALUES = {
7
- "true" => true,
8
- "on" => true,
9
- "1" => true
10
- }.freeze
6
+ VALUES = [true, "true", "on", "1", 1].freeze
7
+ ERROR = [:error, :agreement].freeze
8
+ OK = [:ok, true].freeze
11
9
 
12
10
  def build(options)
13
11
  lambda do |value|
14
- value =
15
- if value.is_a?(TrueClass)
16
- value
17
- elsif value.is_a?(::String) && options[:parse]
18
- VALUES[value]
19
- else
20
- nil
21
- end
12
+ return ERROR if !VALUES.include?(value)
22
13
 
23
- return [:error, :agreement] if value.nil?
24
-
25
- [:ok, value]
14
+ OK
26
15
  end
27
16
  end
28
17
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Explicit::Spec::Bigdecimal
4
+ extend self
5
+
6
+ ERROR = [:error, :bigdecimal].freeze
7
+
8
+ def build(options)
9
+ lambda do |value|
10
+ return ERROR unless value.is_a?(::String) || value.is_a?(::Integer)
11
+
12
+ decimalvalue = BigDecimal(value)
13
+
14
+ if (min = options[:min]) && decimalvalue < min
15
+ return [:error, [:min, min]]
16
+ end
17
+
18
+ if (max = options[:max]) && decimalvalue > max
19
+ return [:error, [:max, max]]
20
+ end
21
+
22
+ [:ok, decimalvalue]
23
+ rescue ArgumentError
24
+ ERROR
25
+ end
26
+ end
27
+ end
@@ -4,26 +4,25 @@ module Explicit::Spec::Boolean
4
4
  extend self
5
5
 
6
6
  VALUES = {
7
+ true => true,
7
8
  "true" => true,
8
9
  "on" => true,
9
10
  "1" => true,
11
+ 1 => true,
12
+ false => false,
10
13
  "false" => false,
11
14
  "off" => false,
12
- "0" => false
15
+ "0" => false,
16
+ 0 => false
13
17
  }.freeze
14
18
 
19
+ ERROR = [:error, :boolean].freeze
20
+
15
21
  def build(options)
16
22
  lambda do |value|
17
- value =
18
- if value.is_a?(TrueClass) || value.is_a?(FalseClass)
19
- value
20
- elsif value.is_a?(::String)
21
- VALUES[value]
22
- else
23
- nil
24
- end
23
+ value = VALUES[value]
25
24
 
26
- return [:error, :boolean] if value.nil?
25
+ return ERROR if value.nil?
27
26
 
28
27
  [:ok, value]
29
28
  end