web_function 0.5.0 → 0.8.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.
@@ -1,67 +1,196 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebFunction
4
+ # Organize, document, and validate endpoints. A package facilitates {Endpoint} discovery and integration by providing
5
+ # standardized metadata about them.
6
+ #
7
+ # A package bundles a base URL together with the endpoints it exposes, as well as optional metadata such as a name,
8
+ # version information, top-level documentation, and a list of common errors.
9
+ #
10
+ # See the [package specification][0] on the Web Function website for the full description of every recognized key and
11
+ # its constraints.
12
+ #
13
+ # [0]: https://webfunction.org/package
14
+ #
4
15
  class Package
5
- def initialize(package)
6
- @package = package
7
- end
16
+ include Flaggable
8
17
 
9
- def base_url
10
- @package["base_url"]
18
+ def initialize(base_url:, pipeline_url: nil, name: nil, version: nil, docs: nil, flags: [], versions: [],
19
+ endpoints: [], errors: [], objects: [])
20
+ @base_url = base_url
21
+ @pipeline_url = pipeline_url
22
+ @name = name
23
+ @version = version
24
+ @docs = docs.to_s
25
+ @flags = flags
26
+ @versions = versions
27
+ @endpoints = endpoints.to_h { |e| [e.name, e] }
28
+ @errors = errors.to_h { |e| [e.code, e] }
29
+ @objects = objects.to_h { |o| [o.name, o] }
11
30
  end
12
31
 
13
- def name
14
- @package["name"]
32
+ class << self
33
+ # Instantiate a new Package from a hash.
34
+ #
35
+ # @param package [Hash] The package hash
36
+ #
37
+ # @return [Package] A new Package instance
38
+ #
39
+ def from_hash(package)
40
+ new(
41
+ base_url: package["base_url"],
42
+ pipeline_url: package["pipeline_url"],
43
+ name: package["name"],
44
+ version: package["version"],
45
+ docs: package["docs"],
46
+ flags: Utils.normalize_array_of_strings(package["flags"]),
47
+ versions: Utils.normalize_array_of_strings(package["versions"]),
48
+ endpoints: Endpoint.from_array(package["endpoints"]),
49
+ errors: DocumentedError.from_array(package["errors"]),
50
+ objects: ObjectSchema.from_array(package["objects"]),
51
+ )
52
+ end
15
53
  end
16
54
 
17
- def flags
18
- unless @package["flags"].is_a?(Array)
19
- return []
20
- end
55
+ # The base URL for the package. Endpoint URLs are formed by joining this base URL with each endpoint's name.
56
+ #
57
+ # This is required for the package to be valid and MUST use the HTTP or HTTPS scheme.
58
+ #
59
+ # @return [String]
60
+ #
61
+ attr_reader :base_url
62
+
63
+ # A function pipelining URL used to batch several endpoint invocations into a single request. See the pipelining
64
+ # specification for more information.
65
+ #
66
+ # @return [String, nil]
67
+ #
68
+ attr_reader :pipeline_url
69
+
70
+ # The name of the package.
71
+ #
72
+ # @return [String, nil]
73
+ #
74
+ attr_reader :name
75
+
76
+ # The version that this package describes. An opaque string.
77
+ #
78
+ # This MUST be present when the `versioned` flag is set. See the versioning specification for more information.
79
+ #
80
+ # @return [String, nil]
81
+ #
82
+ attr_reader :version
21
83
 
22
- @package["flags"].each do |flag|
23
- flag.to_s
84
+ # Top-level documentation for the package. It must be formatted as markdown.
85
+ #
86
+ # @return [String]
87
+ #
88
+ attr_reader :docs
89
+
90
+ # The versions that are available. Each entry is an opaque string.
91
+ #
92
+ # This MUST be present when the `versioned` flag is set. See the versioning specification for more information.
93
+ #
94
+ # @return [Array<String>]
95
+ #
96
+ attr_reader :versions
97
+
98
+ # The {Pipeline} for this package, built from {#pipeline_url}, or `nil` when the package does not declare a
99
+ # pipeline URL.
100
+ #
101
+ # @return [Pipeline, nil]
102
+ #
103
+ def pipeline
104
+ unless pipeline_url
105
+ return
24
106
  end
25
- end
26
107
 
27
- def docs
28
- @package["docs"].to_s
108
+ Pipeline.new(pipeline_url)
29
109
  end
30
110
 
111
+ # The endpoints declared by this package.
112
+ #
113
+ # @return [Array<Endpoint>]
114
+ #
31
115
  def endpoints
32
- unless @package["endpoints"].is_a?(Array)
33
- return []
34
- end
116
+ @endpoints.values
117
+ end
35
118
 
36
- @package["endpoints"].map do |endpoint|
37
- unless endpoint.is_a?(Hash)
38
- next
39
- end
119
+ # Looks up a single endpoint by name. Underscores in the given name are converted to hyphens so that Ruby-style
120
+ # names (e.g. `:find_user_by`) match the hyphenated endpoint names used in packages (e.g. `find-user-by`).
121
+ #
122
+ # @param name [String, Symbol] The name of the endpoint to look up.
123
+ #
124
+ # @return [Endpoint, nil] The matching endpoint, or `nil` if none is found.
125
+ #
126
+ def endpoint(name)
127
+ @endpoints[name.to_s.gsub("_", "-")]
128
+ end
40
129
 
41
- unless endpoint["name"]
42
- next
43
- end
130
+ # The list of common errors that can be returned by any endpoint in this package. Only refer to this list if an
131
+ # endpoint uses the `error_triple` flag. See the error specification for more information.
132
+ #
133
+ # @return [Array<DocumentedError>]
134
+ #
135
+ def errors
136
+ @errors.values
137
+ end
44
138
 
45
- Endpoint.new(endpoint)
46
- end
139
+ # Looks up a single common error by its machine-readable code.
140
+ #
141
+ # @param code [String, Symbol] The error code to look up.
142
+ #
143
+ # @return [DocumentedError, nil] The matching error, or `nil` if none is found.
144
+ #
145
+ def error(code)
146
+ @errors[code.to_s]
47
147
  end
48
148
 
49
- def errors
50
- unless @package["errors"].is_a?(Array)
51
- return []
52
- end
149
+ # The named object definitions declared by this package. Each can be referenced as a refined object type
150
+ # (`object.<name>`) anywhere a type is expected.
151
+ #
152
+ # @return [Array<ObjectSchema>]
153
+ #
154
+ def objects
155
+ @objects.values
156
+ end
53
157
 
54
- @package["errors"].map do |error|
55
- unless error.is_a?(Hash)
56
- next
57
- end
158
+ # Looks up a single object definition by name, resolved for the given context.
159
+ #
160
+ # An `object.` reference appears in either an argument or attribute context, which determines which set of members
161
+ # applies. The `context` selects the member set that is relevant; an object that does not define members for the
162
+ # requested context is treated as absent and `nil` is returned.
163
+ #
164
+ # @param name [String, Symbol] The name of the object to look up.
165
+ # @param context [Symbol] The context the object is referenced in. One of {ObjectSchema::CONTEXTS}
166
+ # (`:arguments` or `:attributes`).
167
+ #
168
+ # @raise [ArgumentError] If the context is not one of {ObjectSchema::CONTEXTS}.
169
+ #
170
+ # @return [ObjectSchema, nil] The matching object, or `nil` if none is found or it defines no members for the
171
+ # given context.
172
+ #
173
+ def object(name, context:)
174
+ object = @objects[name.to_s]
58
175
 
59
- unless error["code"]
60
- next
61
- end
176
+ unless object
177
+ return
178
+ end
62
179
 
63
- DocumentedError.new(error)
180
+ if object.properties(context).empty?
181
+ return
64
182
  end
183
+
184
+ object
185
+ end
186
+
187
+ # Whether the package is versioned, i.e. whether it declares the `versioned` flag. A versioned package is selected
188
+ # using the `Api-Version` header.
189
+ #
190
+ # @return [Boolean]
191
+ #
192
+ def versioned?
193
+ flag?("versioned")
65
194
  end
66
195
  end
67
196
  end
@@ -1,4 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module WebFunction
4
+ # A pipeline is a sequence of steps that are executed in order.
5
+ #
6
+ # @example
7
+ # pipeline = WebFunction::Pipeline.new("https://pipe.example/exec")
8
+ # pipeline.add_step({ url: "https://a", headers: {}, body: {} })
9
+ # pipeline.add_step({ url: "https://b", headers: {}, body: {} })
10
+ # pipeline.execute(returns: :all) # => [{ "a" => 1 }, { "b" => 2 }]
11
+ #
2
12
  class Pipeline
3
13
  def initialize(url)
4
14
  @url = url
@@ -6,6 +16,12 @@ module WebFunction
6
16
  @promises = []
7
17
  end
8
18
 
19
+ # Adds a step to the pipeline.
20
+ #
21
+ # @param step [Hash] The step to add
22
+ #
23
+ # @return [Promise] A new Promise instance
24
+ #
9
25
  def add_step(step)
10
26
  n = @promises.count
11
27
  promise = Promise.new(self, "$[#{n}]")
@@ -16,13 +32,20 @@ module WebFunction
16
32
  promise
17
33
  end
18
34
 
35
+ # Executes the pipeline.
36
+ #
37
+ # @param returns [String, Symbol] The return type or a JSONPath expression to return a specific value.
38
+ #
39
+ # @return [Object] The response returned by the pipeline.
40
+ #
19
41
  def execute(returns: :all)
20
42
  case returns
21
43
  when :all
22
- responses = Endpoint.invoke(@url, args: {
44
+ responses = Request.execute(@url, args: {
23
45
  steps: @steps,
24
46
  returns: "$",
25
- })
47
+ },
48
+ )
26
49
 
27
50
  responses.each_with_index do |response, index|
28
51
  @promises[index].value = response
@@ -32,10 +55,11 @@ module WebFunction
32
55
 
33
56
  responses
34
57
  when :last
35
- response = Endpoint.invoke(@url, args: {
58
+ response = Request.execute(@url, args: {
36
59
  steps: @steps,
37
60
  returns: "$[-1:]",
38
- })
61
+ },
62
+ )
39
63
 
40
64
  @promises.last.value = response
41
65
 
@@ -43,10 +67,11 @@ module WebFunction
43
67
 
44
68
  response
45
69
  else
46
- response = Endpoint.invoke(@url, args: {
70
+ response = Request.execute(@url, args: {
47
71
  steps: @steps,
48
72
  returns: returns,
49
- })
73
+ },
74
+ )
50
75
 
51
76
  reset!
52
77
 
@@ -54,6 +79,10 @@ module WebFunction
54
79
  end
55
80
  end
56
81
 
82
+ # Resets the pipeline.
83
+ #
84
+ # @return [void]
85
+ #
57
86
  def reset!
58
87
  @steps = []
59
88
  @promises = []
@@ -1,4 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
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
+ #
2
11
  class Promise
3
12
  def initialize(pipeline, path)
4
13
  @pipeline = pipeline
@@ -6,19 +15,42 @@ module WebFunction
6
15
  @value = nil
7
16
  end
8
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
+ #
9
25
  class Path
10
26
  def initialize(path)
11
27
  @path = path
12
28
  end
13
29
 
30
+ # Returns the string representation of the path.
31
+ #
32
+ # @return [String] The string representation of the path
33
+ #
14
34
  def to_s
15
35
  @path
16
36
  end
17
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
+ #
18
44
  def to_json(*args)
19
45
  @path.to_json(*args)
20
46
  end
21
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
+ #
22
54
  def [](key)
23
55
  case key
24
56
  when String, Symbol
@@ -30,13 +62,27 @@ module WebFunction
30
62
  end
31
63
  end
32
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
+ #
33
71
  def mutate(path)
34
72
  Path.new(path)
35
73
  end
36
74
  end
37
75
 
76
+ # The value of the promise.
77
+ #
78
+ # @return [Object] The value of the promise
79
+ #
38
80
  attr_writer :value
39
81
 
82
+ # Returns the string representation of the promise.
83
+ #
84
+ # @return [String] The string representation of the promise
85
+ #
40
86
  def to_s
41
87
  if @value
42
88
  @value.to_s
@@ -45,6 +91,12 @@ module WebFunction
45
91
  end
46
92
  end
47
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
+ #
48
100
  def to_json(*args)
49
101
  if @value
50
102
  @value.to_json(*args)
@@ -53,6 +105,12 @@ module WebFunction
53
105
  end
54
106
  end
55
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
+ #
56
114
  def [](key)
57
115
  if @value
58
116
  @value[key]
@@ -61,6 +119,12 @@ module WebFunction
61
119
  end
62
120
  end
63
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
+ #
64
128
  def value
65
129
  unless @value
66
130
  raise WebFunction::UnresolvedPromiseError
@@ -69,6 +133,10 @@ module WebFunction
69
133
  @value
70
134
  end
71
135
 
136
+ # Resolves the promise.
137
+ #
138
+ # @return [Object] The value of the promise
139
+ #
72
140
  def resolve
73
141
  if @value
74
142
  return @value
@@ -0,0 +1,172 @@
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] The response returned by the request
53
+ def execute(url, bearer_auth: nil, version: nil, args: {})
54
+ request = new(url, bearer_auth: bearer_auth, version: version, args: args)
55
+ request.execute
56
+ end
57
+ end
58
+
59
+ self.http_client = proc do |url, headers, body|
60
+ response = Excon.post(url, headers: headers, body: body)
61
+ [response.status, response.body]
62
+ end
63
+
64
+ # The URL of the request.
65
+ #
66
+ # @return [String] The URL of the request
67
+ #
68
+ attr_reader :url
69
+
70
+ # The bearer authentication token.
71
+ #
72
+ # @return [String] The bearer authentication token
73
+ #
74
+ attr_reader :bearer_auth
75
+
76
+ # The API version to use.
77
+ #
78
+ # @return [String] The API version to use
79
+ #
80
+ attr_reader :version
81
+
82
+ # The arguments to send to the request.
83
+ #
84
+ # @return [Hash] The arguments to send to the request
85
+ #
86
+ attr_reader :args
87
+
88
+ # The headers to send to the request.
89
+ #
90
+ # @return [Hash] The headers to send to the request
91
+ #
92
+ def headers
93
+ headers = {
94
+ "Content-Type": "application/json",
95
+ "Accept": "application/json",
96
+ "User-Agent": "webfunction/#{WebFunction::VERSION}",
97
+ "Accept-Encoding": "gzip",
98
+ }
99
+
100
+ if @bearer_auth
101
+ headers["Authorization"] = "Bearer #{@bearer_auth}"
102
+ end
103
+
104
+ if @version
105
+ headers["Api-Version"] = @version
106
+ end
107
+
108
+ headers
109
+ end
110
+
111
+ # Returns the request as a pipeline step.
112
+ #
113
+ # @return [Hash] The request as a pipeline step
114
+ #
115
+ def as_pipeline_step
116
+ {
117
+ url: @url,
118
+ headers: headers,
119
+ body: @args,
120
+ }
121
+ end
122
+
123
+ # Executes the request.
124
+ #
125
+ # @raise [WebFunction::UnexpectedStatusCodeError] If the status code is not 200 or 400
126
+ # @raise [WebFunction::JsonParseError] If the response is not valid JSON
127
+ # @raise [WebFunction::BadRequestError] If the response is a bad request
128
+ #
129
+ # @return [Object] The response returned by the request
130
+ #
131
+ def execute
132
+ status, body = self.class.http_client.call(@url, headers, JSON.generate(@args))
133
+
134
+ unless [200, 400].include?(status)
135
+ raise WebFunction::UnexpectedStatusCodeError.new("Unexpected status code (#{status})",
136
+ details: {
137
+ status_code: status,
138
+ raw_body: body,
139
+ },
140
+ )
141
+ end
142
+
143
+ begin
144
+ result = JSON.parse(body)
145
+ rescue JSON::ParserError => e
146
+ raise WebFunction::JsonParseError.new(e.message,
147
+ details: {
148
+ status_code: status,
149
+ raw_body: body,
150
+ original_exception: e,
151
+ },
152
+ )
153
+ end
154
+
155
+ if status == 400
156
+ code = "WFN_BAD_REQUEST_ERROR"
157
+ message = "Bad request"
158
+ details = { body: result }
159
+
160
+ if result.is_a?(Array) && result.length == 3 && result[0].is_a?(String) && result[1].is_a?(String)
161
+ code = result[0]
162
+ message = result[1]
163
+ details = result[2]
164
+ end
165
+
166
+ raise WebFunction::BadRequestError.new(message, code: code, details: details)
167
+ end
168
+
169
+ result
170
+ end
171
+ end
172
+ 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