webfunction 1.0.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +42 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +598 -0
- data/Rakefile +12 -0
- data/lib/web_function.rb +1 -0
- data/lib/webfunction/argument.rb +123 -0
- data/lib/webfunction/attribute.rb +113 -0
- data/lib/webfunction/client.rb +155 -0
- data/lib/webfunction/documented_error.rb +67 -0
- data/lib/webfunction/endpoint.rb +243 -0
- data/lib/webfunction/flaggable.rb +40 -0
- data/lib/webfunction/object_schema.rb +135 -0
- data/lib/webfunction/package.rb +196 -0
- data/lib/webfunction/page.rb +134 -0
- data/lib/webfunction/pipeline.rb +91 -0
- data/lib/webfunction/promise.rb +150 -0
- data/lib/webfunction/request.rb +177 -0
- data/lib/webfunction/type/any.rb +48 -0
- data/lib/webfunction/type/array_of.rb +58 -0
- data/lib/webfunction/type/base.rb +89 -0
- data/lib/webfunction/type/union.rb +54 -0
- data/lib/webfunction/type.rb +140 -0
- data/lib/webfunction/utils.rb +67 -0
- data/lib/webfunction/version.rb +5 -0
- data/lib/webfunction.rb +52 -0
- data/sig/webfunction.rbs +4 -0
- metadata +131 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
# A promise is a placeholder for a value that will be resolved later.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# pipeline = WebFunction::Pipeline.new("https://pipe.example/exec")
|
|
8
|
+
# promise = pipeline.add_step({})
|
|
9
|
+
# promise.resolve # => { "a" => 1 }
|
|
10
|
+
#
|
|
11
|
+
class Promise
|
|
12
|
+
def initialize(pipeline, path)
|
|
13
|
+
@pipeline = pipeline
|
|
14
|
+
@path = Path.new(path)
|
|
15
|
+
@value = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A path is a JSONPath expression that can be used to resolve a value from a response.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# path = WebFunction::Promise::Path.new("$[0]")
|
|
22
|
+
# path.to_s # => "$[0]"
|
|
23
|
+
# path[0] # => { "a" => 1 }
|
|
24
|
+
#
|
|
25
|
+
class Path
|
|
26
|
+
def initialize(path)
|
|
27
|
+
@path = path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the string representation of the path.
|
|
31
|
+
#
|
|
32
|
+
# @return [String] The string representation of the path
|
|
33
|
+
#
|
|
34
|
+
def to_s
|
|
35
|
+
@path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the JSON representation of the path.
|
|
39
|
+
#
|
|
40
|
+
# @param args [Array] The arguments to pass to the JSON.generate method
|
|
41
|
+
#
|
|
42
|
+
# @return [String] The JSON representation of the path
|
|
43
|
+
#
|
|
44
|
+
def to_json(*args)
|
|
45
|
+
@path.to_json(*args)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns a new path with the given key.
|
|
49
|
+
#
|
|
50
|
+
# @param key [String, Symbol, Integer] The key to add to the path
|
|
51
|
+
#
|
|
52
|
+
# @return [Path] A new Path instance
|
|
53
|
+
#
|
|
54
|
+
def [](key)
|
|
55
|
+
case key
|
|
56
|
+
when String, Symbol
|
|
57
|
+
mutate("#{@path}.#{key}")
|
|
58
|
+
when Integer
|
|
59
|
+
mutate("#{@path}[#{key}]")
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Mutates the path with the given path.
|
|
66
|
+
#
|
|
67
|
+
# @param path [String] The path to mutate
|
|
68
|
+
#
|
|
69
|
+
# @return [Path] A new Path instance
|
|
70
|
+
#
|
|
71
|
+
def mutate(path)
|
|
72
|
+
Path.new(path)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The value of the promise.
|
|
77
|
+
#
|
|
78
|
+
# @return [Object] The value of the promise
|
|
79
|
+
#
|
|
80
|
+
attr_writer :value
|
|
81
|
+
|
|
82
|
+
# Returns the string representation of the promise.
|
|
83
|
+
#
|
|
84
|
+
# @return [String] The string representation of the promise
|
|
85
|
+
#
|
|
86
|
+
def to_s
|
|
87
|
+
if @value
|
|
88
|
+
@value.to_s
|
|
89
|
+
else
|
|
90
|
+
@path.to_s
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the JSON representation of the promise.
|
|
95
|
+
#
|
|
96
|
+
# @param args [Array] The arguments to pass to the JSON.generate method
|
|
97
|
+
#
|
|
98
|
+
# @return [String] The JSON representation of the promise
|
|
99
|
+
#
|
|
100
|
+
def to_json(*args)
|
|
101
|
+
if @value
|
|
102
|
+
@value.to_json(*args)
|
|
103
|
+
else
|
|
104
|
+
@path.to_json(*args)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the value of the promise at the given key.
|
|
109
|
+
#
|
|
110
|
+
# @param key [String, Symbol, Integer] The key to resolve
|
|
111
|
+
#
|
|
112
|
+
# @return [Object] The value of the promise at the given key
|
|
113
|
+
#
|
|
114
|
+
def [](key)
|
|
115
|
+
if @value
|
|
116
|
+
@value[key]
|
|
117
|
+
else
|
|
118
|
+
@path[key]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns the value of the promise.
|
|
123
|
+
#
|
|
124
|
+
# @raise [WebFunction::UnresolvedPromiseError] If the promise is not resolved
|
|
125
|
+
#
|
|
126
|
+
# @return [Object] The value of the promise
|
|
127
|
+
#
|
|
128
|
+
def value
|
|
129
|
+
unless @value
|
|
130
|
+
raise WebFunction::UnresolvedPromiseError
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Resolves the promise.
|
|
137
|
+
#
|
|
138
|
+
# @return [Object] The value of the promise
|
|
139
|
+
#
|
|
140
|
+
def resolve
|
|
141
|
+
if @value
|
|
142
|
+
return @value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@pipeline.execute
|
|
146
|
+
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
# A request allows you to invoke a Web Function endpoint via an HTTP client.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# request = WebFunction::Request.new("https://api.example.com/endpoint")
|
|
8
|
+
# request.execute # => { "a" => 1 }
|
|
9
|
+
#
|
|
10
|
+
class Request
|
|
11
|
+
def initialize(url, bearer_auth: nil, version: nil, args: {})
|
|
12
|
+
@url = url
|
|
13
|
+
@bearer_auth = bearer_auth
|
|
14
|
+
@version = version
|
|
15
|
+
@args = args || {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# The HTTP client used to execute the request.
|
|
20
|
+
#
|
|
21
|
+
# To provide a custom HTTP client instead of the default (which uses Excon),
|
|
22
|
+
# set this to any object responding to #call. For example, a Proc or a lambda.
|
|
23
|
+
#
|
|
24
|
+
# The contract is:
|
|
25
|
+
#
|
|
26
|
+
# client.call(url, headers, body)
|
|
27
|
+
#
|
|
28
|
+
# - url: [String] The full URL to post to (not just the hostname or path).
|
|
29
|
+
# - headers:[Hash<String,String>] HTTP headers, e.g. { "Content-Type" => "application/json" }
|
|
30
|
+
# - body: [String] The JSON body to post.
|
|
31
|
+
#
|
|
32
|
+
# The client must return a two-element Array: [status, body]:
|
|
33
|
+
#
|
|
34
|
+
# - status: [Integer] HTTP status code (e.g. 200, 400, 500)
|
|
35
|
+
# - body: [String] Raw response body as a string
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# WebFunction::Endpoint.http_client = ->(url, headers, args) {
|
|
39
|
+
# http_response = MyHTTP.post(url, headers: headers, body: JSON.generate(args))
|
|
40
|
+
# [http_response.status, http_response.body]
|
|
41
|
+
# }
|
|
42
|
+
#
|
|
43
|
+
attr_accessor :http_client
|
|
44
|
+
|
|
45
|
+
# Executes a request.
|
|
46
|
+
#
|
|
47
|
+
# @param url [String] The URL of the request
|
|
48
|
+
# @param bearer_auth [String] The bearer authentication token
|
|
49
|
+
# @param version [String] The API version to use
|
|
50
|
+
# @param args [Hash] The arguments to send to the request
|
|
51
|
+
#
|
|
52
|
+
# @return [Object, Page] The response returned by the request. Paginated
|
|
53
|
+
# responses are wrapped in a {Page}.
|
|
54
|
+
def execute(url, bearer_auth: nil, version: nil, args: {})
|
|
55
|
+
request = new(url, bearer_auth: bearer_auth, version: version, args: args)
|
|
56
|
+
request.execute
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
self.http_client = proc do |url, headers, body|
|
|
61
|
+
response = Excon.post(url, headers: headers, body: body)
|
|
62
|
+
[response.status, response.body]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The URL of the request.
|
|
66
|
+
#
|
|
67
|
+
# @return [String] The URL of the request
|
|
68
|
+
#
|
|
69
|
+
attr_reader :url
|
|
70
|
+
|
|
71
|
+
# The bearer authentication token.
|
|
72
|
+
#
|
|
73
|
+
# @return [String] The bearer authentication token
|
|
74
|
+
#
|
|
75
|
+
attr_reader :bearer_auth
|
|
76
|
+
|
|
77
|
+
# The API version to use.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] The API version to use
|
|
80
|
+
#
|
|
81
|
+
attr_reader :version
|
|
82
|
+
|
|
83
|
+
# The arguments to send to the request.
|
|
84
|
+
#
|
|
85
|
+
# @return [Hash] The arguments to send to the request
|
|
86
|
+
#
|
|
87
|
+
attr_reader :args
|
|
88
|
+
|
|
89
|
+
# The headers to send to the request.
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash] The headers to send to the request
|
|
92
|
+
#
|
|
93
|
+
def headers
|
|
94
|
+
headers = {
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"Accept": "application/json",
|
|
97
|
+
"User-Agent": "webfunction/#{WebFunction::VERSION}",
|
|
98
|
+
"Accept-Encoding": "gzip",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if @bearer_auth
|
|
102
|
+
headers["Authorization"] = "Bearer #{@bearer_auth}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if @version
|
|
106
|
+
headers["Api-Version"] = @version
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
headers
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the request as a pipeline step.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] The request as a pipeline step
|
|
115
|
+
#
|
|
116
|
+
def as_pipeline_step
|
|
117
|
+
{
|
|
118
|
+
url: @url,
|
|
119
|
+
headers: headers,
|
|
120
|
+
body: @args,
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Executes the request.
|
|
125
|
+
#
|
|
126
|
+
# When the response matches the pagination contract (`page`, `next`, and
|
|
127
|
+
# `previous` keys), it is wrapped in a {Page} so callers can navigate with
|
|
128
|
+
# {Page#next_page} and {Page#previous_page}.
|
|
129
|
+
#
|
|
130
|
+
# @raise [WebFunction::UnexpectedStatusCodeError] If the status code is not 200 or 400
|
|
131
|
+
# @raise [WebFunction::JsonParseError] If the response is not valid JSON
|
|
132
|
+
# @raise [WebFunction::BadRequestError] If the response is a bad request
|
|
133
|
+
#
|
|
134
|
+
# @return [Object, Page] The response returned by the request
|
|
135
|
+
#
|
|
136
|
+
def execute
|
|
137
|
+
status, body = self.class.http_client.call(@url, headers, JSON.generate(@args))
|
|
138
|
+
|
|
139
|
+
unless [200, 400].include?(status)
|
|
140
|
+
raise WebFunction::UnexpectedStatusCodeError.new("Unexpected status code (#{status})",
|
|
141
|
+
details: {
|
|
142
|
+
status_code: status,
|
|
143
|
+
raw_body: body,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
result = JSON.parse(body)
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
raise WebFunction::JsonParseError.new(e.message,
|
|
152
|
+
details: {
|
|
153
|
+
status_code: status,
|
|
154
|
+
raw_body: body,
|
|
155
|
+
original_exception: e,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if status == 400
|
|
161
|
+
code = "WFN_BAD_REQUEST_ERROR"
|
|
162
|
+
message = "Bad request"
|
|
163
|
+
details = { body: result }
|
|
164
|
+
|
|
165
|
+
if result.is_a?(Array) && result.length == 3 && result[0].is_a?(String) && result[1].is_a?(String)
|
|
166
|
+
code = result[0]
|
|
167
|
+
message = result[1]
|
|
168
|
+
details = result[2]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
raise WebFunction::BadRequestError.new(message, code: code, details: details)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Page.wrap(result, request: self)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
module Type
|
|
5
|
+
class Any
|
|
6
|
+
def base_type
|
|
7
|
+
"any"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def refinement
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inspect
|
|
15
|
+
"#<any>"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def format(_format = :default)
|
|
19
|
+
"any"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s
|
|
23
|
+
format(:default)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.is_a?(Any)
|
|
28
|
+
end
|
|
29
|
+
alias eql? ==
|
|
30
|
+
|
|
31
|
+
def hash
|
|
32
|
+
Any.hash
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def objects
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def without_refinements
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def valid?(_value)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
module Type
|
|
5
|
+
class ArrayOf
|
|
6
|
+
attr_reader :base_type, :of
|
|
7
|
+
|
|
8
|
+
def initialize(of)
|
|
9
|
+
@base_type = "array"
|
|
10
|
+
@of = of
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def refinement
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def inspect
|
|
18
|
+
"#<ArrayOf #{@of.inspect}>"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format(format = :default)
|
|
22
|
+
case format
|
|
23
|
+
when :base
|
|
24
|
+
"array"
|
|
25
|
+
when :compact, :default
|
|
26
|
+
"array<#{@of.format(format)}>"
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "unknown format: #{format.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
format(:default)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ==(other)
|
|
37
|
+
other.is_a?(ArrayOf) && other.of == @of
|
|
38
|
+
end
|
|
39
|
+
alias eql? ==
|
|
40
|
+
|
|
41
|
+
def hash
|
|
42
|
+
[ArrayOf, @of].hash
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def objects
|
|
46
|
+
@of.objects
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def without_refinements
|
|
50
|
+
ArrayOf.new(@of.without_refinements)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def valid?(value)
|
|
54
|
+
value.is_a?(Array) && value.all? { |element| @of.valid?(element) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
module Type
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :base_type, :refinement
|
|
7
|
+
|
|
8
|
+
def initialize(base_type:, refinement: nil)
|
|
9
|
+
@base_type = base_type
|
|
10
|
+
@refinement = refinement
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
if @refinement
|
|
15
|
+
"#<#{@base_type} #{@refinement}>"
|
|
16
|
+
else
|
|
17
|
+
"#<#{@base_type}>"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format(format = :default)
|
|
22
|
+
case format
|
|
23
|
+
when :compact
|
|
24
|
+
@refinement || @base_type
|
|
25
|
+
when :base
|
|
26
|
+
@base_type
|
|
27
|
+
when :default
|
|
28
|
+
if @refinement
|
|
29
|
+
"#{@base_type}.#{@refinement}"
|
|
30
|
+
else
|
|
31
|
+
@base_type
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "unknown format: #{format.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
format(:default)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(Base) && other.base_type == @base_type && other.refinement == @refinement
|
|
44
|
+
end
|
|
45
|
+
alias eql? ==
|
|
46
|
+
|
|
47
|
+
def hash
|
|
48
|
+
[Base, @base_type, @refinement].hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def objects
|
|
52
|
+
if @base_type == "object" && @refinement
|
|
53
|
+
[@refinement]
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def without_refinements
|
|
60
|
+
return self if @refinement.nil?
|
|
61
|
+
|
|
62
|
+
Base.new(base_type: @base_type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def valid?(value)
|
|
66
|
+
case @base_type
|
|
67
|
+
when "string"
|
|
68
|
+
value.is_a?(String) && refinement_valid?(value)
|
|
69
|
+
when "number"
|
|
70
|
+
value.is_a?(Numeric) && !value.is_a?(Complex) && refinement_valid?(value)
|
|
71
|
+
when "object"
|
|
72
|
+
value.is_a?(Hash)
|
|
73
|
+
when "boolean"
|
|
74
|
+
value == true || value == false
|
|
75
|
+
when "null"
|
|
76
|
+
value.nil?
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def refinement_valid?(value)
|
|
85
|
+
@refinement.nil? || REFINEMENT_VALIDATORS.fetch(@refinement).call(value)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebFunction
|
|
4
|
+
module Type
|
|
5
|
+
class Union
|
|
6
|
+
attr_reader :members
|
|
7
|
+
|
|
8
|
+
def initialize(members)
|
|
9
|
+
@members = members
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def base_type
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def refinement
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inspect
|
|
21
|
+
"#<Union #{@members.map(&:inspect).join(" | ")}>"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def format(format = :default)
|
|
25
|
+
@members.map { |member| member.format(format) }.join(" | ")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
format(:default)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ==(other)
|
|
33
|
+
other.is_a?(Union) && other.members == @members
|
|
34
|
+
end
|
|
35
|
+
alias eql? ==
|
|
36
|
+
|
|
37
|
+
def hash
|
|
38
|
+
[Union, @members].hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def objects
|
|
42
|
+
@members.flat_map(&:objects).uniq
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def without_refinements
|
|
46
|
+
Type.union(@members.map(&:without_refinements))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def valid?(value)
|
|
50
|
+
@members.any? { |member| member.valid?(value) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "type/base"
|
|
7
|
+
require_relative "type/array_of"
|
|
8
|
+
require_relative "type/union"
|
|
9
|
+
require_relative "type/any"
|
|
10
|
+
|
|
11
|
+
module WebFunction
|
|
12
|
+
module Type
|
|
13
|
+
ALLOWED_REFINEMENTS = {
|
|
14
|
+
"number" => %w[u32 u64 i32 i64 f32 f64 timestamp].freeze,
|
|
15
|
+
"string" => %w[date time datetime uuid base64 email phone url uri ipv4 ipv6 hostname].freeze,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Value-level validators for each refinement, keyed by refinement name. Refinement names are unique across base
|
|
19
|
+
# types, so a flat table is enough. Must stay in sync with {ALLOWED_REFINEMENTS}.
|
|
20
|
+
REFINEMENT_VALIDATORS = {
|
|
21
|
+
"u32" => ->(v) { v.is_a?(Integer) && v.between?(0, 0xFFFFFFFF) },
|
|
22
|
+
"u64" => ->(v) { v.is_a?(Integer) && v.between?(0, 0xFFFFFFFFFFFFFFFF) },
|
|
23
|
+
"i32" => ->(v) { v.is_a?(Integer) && v.between?(-0x80000000, 0x7FFFFFFF) },
|
|
24
|
+
"i64" => ->(v) { v.is_a?(Integer) && v.between?(-0x8000000000000000, 0x7FFFFFFFFFFFFFFF) },
|
|
25
|
+
"f32" => ->(v) { v.is_a?(Numeric) && v.to_f.finite? && v.to_f.abs <= 3.4028235e38 },
|
|
26
|
+
"f64" => ->(v) { v.is_a?(Numeric) && v.to_f.finite? },
|
|
27
|
+
"timestamp" => ->(v) { v.is_a?(Integer) && v >= 0 },
|
|
28
|
+
"date" => ->(v) { v.match?(/\A\d{4}-\d{2}-\d{2}\z/) },
|
|
29
|
+
"time" => ->(v) { v.match?(/\A\d{2}:\d{2}:\d{2}(\.\d+)?\z/) },
|
|
30
|
+
"datetime" => ->(v) { v.match?(/\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?\z/) },
|
|
31
|
+
"uuid" => ->(v) { v.match?(/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/) },
|
|
32
|
+
"base64" => ->(v) { (v.length % 4).zero? && v.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z}) },
|
|
33
|
+
"email" => ->(v) { v.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/) },
|
|
34
|
+
"phone" => ->(v) { v.match?(/\A\+[1-9]\d{1,14}\z/) },
|
|
35
|
+
"url" => ->(v) { (uri = URI.parse(v)).is_a?(URI::HTTP) && !uri.host.nil? rescue false },
|
|
36
|
+
"uri" => ->(v) { !URI.parse(v).scheme.nil? rescue false },
|
|
37
|
+
"ipv4" => ->(v) { IPAddr.new(v).ipv4? rescue false },
|
|
38
|
+
"ipv6" => ->(v) { IPAddr.new(v).ipv6? rescue false },
|
|
39
|
+
"hostname" => lambda do |v|
|
|
40
|
+
v.match?(%r{\A(?=.{1,253}\z)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z})
|
|
41
|
+
end,
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
def self.parse(raw)
|
|
45
|
+
types = [*raw].map { |type| Type.detect(type) }.compact
|
|
46
|
+
|
|
47
|
+
if types.empty?
|
|
48
|
+
return Type.any
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Type.union(types)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.detect(raw)
|
|
55
|
+
case raw
|
|
56
|
+
when String
|
|
57
|
+
Type.base(raw)
|
|
58
|
+
when Array
|
|
59
|
+
types = raw.map { |type| Type.detect(type) }.compact
|
|
60
|
+
|
|
61
|
+
if types.empty?
|
|
62
|
+
return Type.array(Type.any)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Type.array(Type.union(types))
|
|
66
|
+
else
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.base(type)
|
|
72
|
+
base_type, refinement = type.split(".", 2)
|
|
73
|
+
|
|
74
|
+
case base_type
|
|
75
|
+
when "string"
|
|
76
|
+
if refinement && !ALLOWED_REFINEMENTS["string"].include?(refinement)
|
|
77
|
+
return Type.string
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Type.string(refinement)
|
|
81
|
+
when "number"
|
|
82
|
+
if refinement && !ALLOWED_REFINEMENTS["number"].include?(refinement)
|
|
83
|
+
return Type.number
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Type.number(refinement)
|
|
87
|
+
when "object"
|
|
88
|
+
Type.object(refinement)
|
|
89
|
+
when "array"
|
|
90
|
+
Type.array
|
|
91
|
+
when "boolean"
|
|
92
|
+
Type.boolean
|
|
93
|
+
when "null"
|
|
94
|
+
Type.null
|
|
95
|
+
when "any"
|
|
96
|
+
Type.any
|
|
97
|
+
else
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.string(refinement = nil)
|
|
103
|
+
Base.new(base_type: "string", refinement: refinement)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.number(refinement = nil)
|
|
107
|
+
Base.new(base_type: "number", refinement: refinement)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.object(refinement = nil)
|
|
111
|
+
Base.new(base_type: "object", refinement: refinement)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.array(of = any)
|
|
115
|
+
ArrayOf.new(of)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.boolean
|
|
119
|
+
Base.new(base_type: "boolean")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.null
|
|
123
|
+
Base.new(base_type: "null")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.union(types)
|
|
127
|
+
types = types.uniq
|
|
128
|
+
|
|
129
|
+
if types.length > 1
|
|
130
|
+
Union.new(types)
|
|
131
|
+
else
|
|
132
|
+
types.first
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.any
|
|
137
|
+
Any.new
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|