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.
- checksums.yaml +7 -0
- data/lib/bluepine.rb +35 -0
- data/lib/bluepine/assertions.rb +80 -0
- data/lib/bluepine/attributes.rb +92 -0
- data/lib/bluepine/attributes/array_attribute.rb +11 -0
- data/lib/bluepine/attributes/attribute.rb +130 -0
- data/lib/bluepine/attributes/boolean_attribute.rb +22 -0
- data/lib/bluepine/attributes/currency_attribute.rb +19 -0
- data/lib/bluepine/attributes/date_attribute.rb +11 -0
- data/lib/bluepine/attributes/float_attribute.rb +13 -0
- data/lib/bluepine/attributes/integer_attribute.rb +14 -0
- data/lib/bluepine/attributes/ip_address_attribute.rb +15 -0
- data/lib/bluepine/attributes/number_attribute.rb +24 -0
- data/lib/bluepine/attributes/object_attribute.rb +71 -0
- data/lib/bluepine/attributes/schema_attribute.rb +23 -0
- data/lib/bluepine/attributes/string_attribute.rb +36 -0
- data/lib/bluepine/attributes/time_attribute.rb +19 -0
- data/lib/bluepine/attributes/uri_attribute.rb +19 -0
- data/lib/bluepine/attributes/visitor.rb +136 -0
- data/lib/bluepine/endpoint.rb +102 -0
- data/lib/bluepine/endpoints/method.rb +90 -0
- data/lib/bluepine/endpoints/params.rb +115 -0
- data/lib/bluepine/error.rb +17 -0
- data/lib/bluepine/functions.rb +49 -0
- data/lib/bluepine/generators.rb +3 -0
- data/lib/bluepine/generators/generator.rb +16 -0
- data/lib/bluepine/generators/grpc/generator.rb +10 -0
- data/lib/bluepine/generators/open_api/generator.rb +205 -0
- data/lib/bluepine/generators/open_api/property_generator.rb +111 -0
- data/lib/bluepine/registry.rb +75 -0
- data/lib/bluepine/resolvable.rb +11 -0
- data/lib/bluepine/resolver.rb +99 -0
- data/lib/bluepine/serializer.rb +125 -0
- data/lib/bluepine/serializers/serializable.rb +25 -0
- data/lib/bluepine/validator.rb +205 -0
- data/lib/bluepine/validators/normalizable.rb +25 -0
- data/lib/bluepine/validators/proxy.rb +77 -0
- data/lib/bluepine/validators/validatable.rb +48 -0
- data/lib/bluepine/version.rb +3 -0
- 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,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,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
|