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.
@@ -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