bluepine 0.1.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/lib/bluepine.rb +35 -0
  3. data/lib/bluepine/assertions.rb +80 -0
  4. data/lib/bluepine/attributes.rb +92 -0
  5. data/lib/bluepine/attributes/array_attribute.rb +11 -0
  6. data/lib/bluepine/attributes/attribute.rb +130 -0
  7. data/lib/bluepine/attributes/boolean_attribute.rb +22 -0
  8. data/lib/bluepine/attributes/currency_attribute.rb +19 -0
  9. data/lib/bluepine/attributes/date_attribute.rb +11 -0
  10. data/lib/bluepine/attributes/float_attribute.rb +13 -0
  11. data/lib/bluepine/attributes/integer_attribute.rb +14 -0
  12. data/lib/bluepine/attributes/ip_address_attribute.rb +15 -0
  13. data/lib/bluepine/attributes/number_attribute.rb +24 -0
  14. data/lib/bluepine/attributes/object_attribute.rb +71 -0
  15. data/lib/bluepine/attributes/schema_attribute.rb +23 -0
  16. data/lib/bluepine/attributes/string_attribute.rb +36 -0
  17. data/lib/bluepine/attributes/time_attribute.rb +19 -0
  18. data/lib/bluepine/attributes/uri_attribute.rb +19 -0
  19. data/lib/bluepine/attributes/visitor.rb +136 -0
  20. data/lib/bluepine/endpoint.rb +102 -0
  21. data/lib/bluepine/endpoints/method.rb +90 -0
  22. data/lib/bluepine/endpoints/params.rb +115 -0
  23. data/lib/bluepine/error.rb +17 -0
  24. data/lib/bluepine/functions.rb +49 -0
  25. data/lib/bluepine/generators.rb +3 -0
  26. data/lib/bluepine/generators/generator.rb +16 -0
  27. data/lib/bluepine/generators/grpc/generator.rb +10 -0
  28. data/lib/bluepine/generators/open_api/generator.rb +205 -0
  29. data/lib/bluepine/generators/open_api/property_generator.rb +111 -0
  30. data/lib/bluepine/registry.rb +75 -0
  31. data/lib/bluepine/resolvable.rb +11 -0
  32. data/lib/bluepine/resolver.rb +99 -0
  33. data/lib/bluepine/serializer.rb +125 -0
  34. data/lib/bluepine/serializers/serializable.rb +25 -0
  35. data/lib/bluepine/validator.rb +205 -0
  36. data/lib/bluepine/validators/normalizable.rb +25 -0
  37. data/lib/bluepine/validators/proxy.rb +77 -0
  38. data/lib/bluepine/validators/validatable.rb +48 -0
  39. data/lib/bluepine/version.rb +3 -0
  40. metadata +208 -0
@@ -0,0 +1,90 @@
1
+ module Bluepine
2
+ module Endpoints
3
+ # Represents HTTP method
4
+ #
5
+ # @example Create new +POST+ {Method}
6
+ # Method.new(:post, action: create, path: "/", schema: :user, as: :list)
7
+ #
8
+ # @example
9
+ # Method.new(:post, {
10
+ # action: :create,
11
+ # path: "/",
12
+ # validators: [CustomValidator]
13
+ # })
14
+ class Method
15
+ DEFAULT_OPTIONS = {
16
+ as: nil,
17
+ params: [],
18
+ exclude: false,
19
+ schema: nil,
20
+ status: 200,
21
+ title: nil,
22
+ description: nil,
23
+ validators: [],
24
+ }.freeze
25
+
26
+ attr_reader :verb, :path, :action, :params, :schema, :status, :as
27
+ attr_accessor :title, :description
28
+
29
+ def initialize(verb, action:, path: "/", **options)
30
+ @options = DEFAULT_OPTIONS.merge(options)
31
+ @verb = verb
32
+ @path = path
33
+ @action = action
34
+
35
+ # Create ParamsAttribute instance
36
+ @params = create_params(action, options.slice(:params, :schema, :exclude))
37
+ @schema = @options[:schema]
38
+ @as = @options[:as]
39
+ @status = @options[:status]
40
+ @title = @options[:title]
41
+ @description = @options[:description]
42
+ @validator = nil
43
+ @validators = @options[:validators]
44
+ @resolver = nil
45
+ @result = nil
46
+ end
47
+
48
+ def validate(params = {}, resolver = nil)
49
+ @validator = create_validator(@resolver || resolver)
50
+ @result = @validator.validate(@params, params, validators: @validators)
51
+ end
52
+
53
+ def valid?(*args)
54
+ validate(*args)
55
+
56
+ @result&.errors&.empty?
57
+ end
58
+
59
+ def errors
60
+ @result&.errors
61
+ end
62
+
63
+ # Does it have request body? (only non GET verb can have request body)
64
+ def body?
65
+ Bluepine::Endpoint::HTTP_METHODS_WITH_BODY.include?(@verb) && @params.keys.any?
66
+ end
67
+
68
+ def build_params(default = {}, resolver = nil)
69
+ @resolver = resolver if resolver
70
+ @params = @params.build(default, resolver)
71
+ end
72
+
73
+ def permit_params(params = {}, target = nil)
74
+ return params unless params.respond_to?(:permit)
75
+
76
+ params.permit(*@params.permit(params))
77
+ end
78
+
79
+ private
80
+
81
+ def create_params(action, **options)
82
+ Bluepine::Endpoints::Params.new(action, **options)
83
+ end
84
+
85
+ def create_validator(*args)
86
+ Bluepine::Validator.new(*args)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,115 @@
1
+ module Bluepine
2
+ module Endpoints
3
+ # Usage
4
+ #
5
+ # Params.new(:create)
6
+ # Params.new(:index, params: :list)
7
+ # Params.new(:create, params: %i[amount currency])
8
+ # Params.new(:create, params: %i[amount], exclude: true)
9
+ # Params.new(:create, params: false)
10
+ # Params.new :create, params: -> {
11
+ # integer :amount
12
+ # }
13
+ # Params.new(:index, schema: :user, as: :list)
14
+ #
15
+ class Params < Attributes::ObjectAttribute
16
+ include Bluepine::Assertions
17
+ include Bluepine::Resolvable
18
+
19
+ InvalidType = Bluepine::Error.create("Invalid params type")
20
+ NotBuilt = Bluepine::Error.create("Params need to be built first")
21
+
22
+ DEFAULT_OPTIONS = {
23
+ exclude: false,
24
+ schema: nil,
25
+ built: false,
26
+ }.freeze
27
+
28
+ attr_reader :action, :params, :schema
29
+
30
+ def initialize(action, params: false, **options, &block)
31
+ super(action, options.except(DEFAULT_OPTIONS.keys))
32
+
33
+ options = DEFAULT_OPTIONS.merge(options)
34
+ @action = action.to_sym
35
+ @exclude = options[:exclude]
36
+ @schema = options[:schema]
37
+ @params = block_given? ? block : params
38
+ @built = options[:built]
39
+ end
40
+
41
+ def build(default = {}, resolver = nil)
42
+ # Flag as built
43
+ @built = true
44
+
45
+ case @params
46
+ when Params
47
+ @params
48
+ when true
49
+ # use default params
50
+ default
51
+ when false
52
+ # use no params
53
+ self
54
+ when Proc
55
+ instance_exec(&@params)
56
+ self
57
+ when Symbol
58
+ # use params from other service
59
+ resolver.endpoint(@params).params
60
+ when Array
61
+ assert_subset_of(default.keys, @params)
62
+
63
+ # override default params by using specified symbol
64
+ keys = @exclude ? default.keys - @params : @params
65
+ keys.each { |name| self[name] = default[name] }
66
+ self
67
+ else
68
+ raise InvalidType
69
+ end
70
+ end
71
+
72
+ def built?
73
+ @built
74
+ end
75
+
76
+ # Build permitted params for ActionController::Params
77
+ def permit(params = {})
78
+ raise NotBuilt unless built?
79
+
80
+ build_permitted_params(attributes, params)
81
+ end
82
+
83
+ private
84
+
85
+ def build_permitted_params(attrs, params = {})
86
+ attrs.map do |name, attr|
87
+ # permit array
88
+ # TODO: params.permit(:foo, array: [:key1, :key2])
89
+ next { name => [] } if attr.kind_of?(Bluepine::Attributes::ArrayAttribute)
90
+
91
+ # permit non-object
92
+ next name unless attr.kind_of?(Bluepine::Attributes::ObjectAttribute)
93
+
94
+ # Rails 5.0 doesn't support arbitary hash params
95
+ # 5.1+ supports this, via `key: {}` empty hash.
96
+ #
97
+ # The work around is to get hash keys from params
98
+ # and assign them to permit keys
99
+ # params = params&.fetch(name, {})
100
+ data = params&.fetch(name, {})
101
+ keys = build_permitted_params(attr.attributes, data)
102
+
103
+ { attr.name => normalize_permitted_params(keys, data) }
104
+ end
105
+ end
106
+
107
+ def normalize_permitted_params(keys, params = {})
108
+ return keys unless keys.empty?
109
+ return {} if params.empty?
110
+
111
+ params.keys
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,17 @@
1
+ module Bluepine
2
+ # @example
3
+ # InvaldKey = Error.create("Invalid %s key")
4
+ # raise InvalidKey, "id"
5
+ class Error < StandardError
6
+ def self.create(msg)
7
+ Class.new(Error) do
8
+ MESSAGE.replace msg
9
+ end
10
+ end
11
+
12
+ MESSAGE = "Error"
13
+ def initialize(*args)
14
+ super args.any? ? MESSAGE % args : MESSAGE
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ module Bluepine
2
+ # FP helpers
3
+ module Functions
4
+ class Result
5
+ attr_reader :value, :errors
6
+
7
+ def initialize(value, errors = nil)
8
+ @value = value
9
+ @errors = errors
10
+ end
11
+
12
+ def compose(f)
13
+ return self if errors
14
+
15
+ f.(value)
16
+ end
17
+ end
18
+
19
+ def result(value, errors = nil)
20
+ Result.new(value, errors)
21
+ end
22
+
23
+ # Compose functions
24
+ # compose : (a -> b) -> (b -> c) -> a -> c
25
+ def compose(*fns)
26
+ ->(v) {
27
+ fns.reduce(fns.shift.(v)) do |x, f|
28
+ x.respond_to?(:compose) ? x.compose(f) : f.(x)
29
+ end
30
+ }
31
+ end
32
+
33
+ # A composition that early returns value
34
+ # f : a -> b
35
+ # g : a -> c
36
+ def compose_result(*fns)
37
+ ->(v) {
38
+ fns.reduce(fns.shift.(v)) do |x, f|
39
+ x ? x : f.(v)
40
+ end
41
+ }
42
+ end
43
+
44
+ # Curry instance method
45
+ def curry(method, *args)
46
+ self.method(method).curry[*args]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ require "bluepine/generators/generator"
2
+ require "bluepine/generators/open_api/generator"
3
+ require "bluepine/generators/open_api/property_generator"
@@ -0,0 +1,16 @@
1
+ module Bluepine
2
+ module Generators
3
+ class Generator
4
+ include Bluepine::Assertions
5
+ include Bluepine::Resolvable
6
+
7
+ def initialize(resolver = nil)
8
+ @resolver = resolver
9
+ end
10
+
11
+ def generate(*args)
12
+ raise NotImplementedError
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ module Bluepine
2
+ module API
3
+ module Generators
4
+ module Grpc
5
+ class Generator
6
+ end
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,205 @@
1
+ require "ostruct"
2
+
3
+ module Bluepine
4
+ module Generators
5
+ module OpenAPI
6
+ # Generate Open API v3 Specifications
7
+ #
8
+ # @example
9
+ # resolver = Resolver.new(schemas: [])
10
+ # generator = OpenApi::Generator.new(resolver, {
11
+ # title: "Awesome API",
12
+ # version: "1.0.0",
13
+ # # other infos ...
14
+ # })
15
+ #
16
+ # generator = OpenApi::Generator.new(resolver) do
17
+ # title "Awesome API"
18
+ # version "1.0.0"
19
+ # servers []
20
+ # end
21
+ #
22
+ # generator.generate # => { }
23
+ class Generator < Bluepine::Generators::Generator
24
+ OPEN_API_VERSION = "3.0.0".freeze
25
+ OPEN_API_ID_REGEX = /:([\w]+)/.freeze
26
+ EMPTY_RESPONSE = "Response".freeze
27
+
28
+ OPTIONS = {
29
+ version: nil,
30
+ title: nil,
31
+ description: nil,
32
+ servers: []
33
+ }
34
+
35
+ def initialize(resolver = nil, options = {})
36
+ super(resolver)
37
+
38
+ @options = OPTIONS.merge(options)
39
+ end
40
+
41
+ def generate
42
+ {
43
+ openapi: OPEN_API_VERSION,
44
+ info: {
45
+ version: @options[:version],
46
+ title: @options[:title],
47
+ description: @options[:description],
48
+ },
49
+ servers: generate_server_urls(@options[:servers]),
50
+ paths: generate_paths(resolver.endpoints.keys),
51
+ components: {
52
+ schemas: generate_schemas(resolver.schemas.keys),
53
+ },
54
+ }
55
+ end
56
+
57
+ # share for both specs
58
+ def generate_params(method, base_url = nil)
59
+ generate_path_params(method, base_url) + generate_query_params(method)
60
+ end
61
+
62
+ def generate_param(param, in: :query, schema: nil)
63
+ return unless param.serializable?
64
+
65
+ {
66
+ name: param.name.to_s,
67
+ in: binding.local_variable_get(:in),
68
+ schema: PropertyGenerator.generate(param, schema: schema),
69
+ }.tap do |parameter|
70
+ parameter[:required] = param.required if param.required
71
+ parameter[:deprecated] = param.deprecated if param.deprecated
72
+ parameter[:description] = param.description if param.description
73
+ end
74
+ end
75
+
76
+ # path contains id? e.g. /users/:id
77
+ def generate_path_params(method, base_url = nil)
78
+ extract_ids(url(base_url, method.path))
79
+ .map { |id| Attributes::StringAttribute.new(id, required: true) }
80
+ .map { |id| generate_param(id, in: :path) }
81
+ end
82
+
83
+ # convert request body to `query` params when HTTP verb is `GET`
84
+ def generate_query_params(method)
85
+ return [] unless method.verb == :get
86
+
87
+ method.params.attributes.values.each_with_object([]) do |param, params|
88
+ # include original schema when method.schema differs from params.schema
89
+ schema = method.params.schema if method.schema != method.params.schema
90
+ params << generate_param(param, schema: schema)
91
+ end.compact
92
+ end
93
+
94
+ def group_methods_by_path(methods)
95
+ methods.values.each_with_object({}) do |method, paths|
96
+ paths[method.path] ||= []
97
+ paths[method.path] << method
98
+ end
99
+ end
100
+
101
+ # -- end --
102
+ def generate_server_urls(urls = [])
103
+ urls.map { |name, url| { url: url, description: name } }
104
+ end
105
+
106
+ def generate_paths(services)
107
+ services.each_with_object({}) do |name, paths|
108
+ # no need to initialize
109
+ next unless (endpoint = resolver.endpoint(name))
110
+
111
+ generate_operations(endpoint, paths)
112
+ end
113
+ end
114
+
115
+ def generate_operations(endpoint, paths)
116
+ base_url = endpoint.path
117
+ group_methods_by_path(endpoint.methods(resolver)).each do |path, methods|
118
+ resource_url = convert_id_params(url(base_url, path))
119
+ paths[resource_url] = {}
120
+
121
+ methods.each do |method|
122
+ paths[resource_url][method.verb] = generate_operation(method, base_url)
123
+ end
124
+ end
125
+ end
126
+
127
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operationObject
128
+ def generate_operation(method, base_url = nil)
129
+ {
130
+ tags: [method.schema.to_s.humanize.pluralize],
131
+ parameters: generate_params(method, base_url),
132
+ }.tap do |operation|
133
+ operation[:requestBody] = generate_request_params(method) if method.body?
134
+ operation[:summary] = method.description if method.description
135
+ operation[:responses] = generate_responses(method)
136
+ end
137
+ end
138
+
139
+ def generate_request_params(method)
140
+ {
141
+ "content": {
142
+ "application/x-www-form-urlencoded": {
143
+ schema: generate_schema(method.params),
144
+ },
145
+ },
146
+ }
147
+ end
148
+
149
+ def generate_responses(method)
150
+ {
151
+ method.status => generate_response(method),
152
+ }
153
+ end
154
+
155
+ def generate_response(method)
156
+ {
157
+ description: method.schema&.to_s&.humanize || EMPTY_RESPONSE,
158
+ }.tap do |response|
159
+ response[:content] = generate_json_response(method) if method.schema.present?
160
+ end
161
+ end
162
+
163
+ def generate_json_response(method)
164
+ {
165
+ "application/json": {
166
+ schema: PropertyGenerator.generate(method.schema, as: method.as),
167
+ },
168
+ }
169
+ end
170
+
171
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject
172
+ def generate_schemas(serializers)
173
+ serializers.each_with_object({}) do |name, schemas|
174
+ # no need to initialize
175
+ next unless (object = resolver.schema(name))
176
+
177
+ schemas[name] = generate_schema(object)
178
+ end
179
+ end
180
+
181
+ def generate_schema(object)
182
+ self.class.assert_kind_of Bluepine::Attributes::Attribute, object
183
+
184
+ PropertyGenerator.generate(object)
185
+ end
186
+
187
+ private
188
+
189
+ # convert :id to {id} format
190
+ def convert_id_params(url)
191
+ url.gsub(OPEN_API_ID_REGEX, '{\1}')
192
+ end
193
+
194
+ def extract_ids(path)
195
+ path.scan(OPEN_API_ID_REGEX).flatten
196
+ end
197
+
198
+ # join relative url
199
+ def url(*parts)
200
+ "/" + parts.compact.map { |part| part.gsub(/^\/|\/$/, "") }.reject(&:blank?).join("/")
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end