bluepine 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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