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