web_function 0.4.1 → 0.6.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,111 +1,251 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebFunction
4
+ # Represents an endpoint as described in a Web Function package.
5
+ #
6
+ # An endpoint defines an operation that can be performed via a Web Function API. Endpoints declare their name,
7
+ # documentation, arguments (inputs), attributes (outputs), and the possible errors that may occur when invoking them.
8
+ #
9
+ # Endpoints are described as objects in each package under the `"endpoints"` key. For more information, see:
10
+ #
11
+ # - [Web Function package docs](https://webfunction.org/package)
12
+ # - [Web Function endpoint docs](https://webfunction.org/endpoint)
13
+ #
14
+ # This class provides methods for accessing endpoint metadata (name, docs, arguments, attributes, errors) and
15
+ # supports invocation via HTTP.
16
+ #
17
+ # Typical tasks include:
18
+ #
19
+ # - Querying endpoint name or documentation
20
+ # - Enumerating the arguments or attributes definitions
21
+ # - Invoking the endpoint through HTTP using required inputs
22
+ #
23
+ # See: https://webfunction.org/endpoint for more details on endpoint structure and contract.
24
+ #
4
25
  class Endpoint
5
- def initialize(endpoint)
6
- @endpoint = endpoint
26
+ include Flaggable
27
+
28
+ def initialize(name:, returns: [], hints: [], flags: [], group: nil, docs: nil, arguments: [], attributes: [], errors: [])
29
+ @name = name
30
+ @returns = returns
31
+ @hints = hints
32
+ @flags = flags
33
+ @group = group
34
+ @docs = docs
35
+ @arguments = arguments.to_h { |a| [a.name, a] }
36
+ @attributes = attributes.to_h { |a| [a.name, a] }
37
+ @errors = errors.to_h { |e| [e.code, e] }
7
38
  end
8
39
 
9
- def self.invoke(url, bearer_auth: nil, args: {}, as_step: false)
10
- headers = {
11
- "Content-Type": "application/json",
12
- "Accept": "application/json",
13
- "User-Agent": "webfunction/#{WebFunction::VERSION}",
14
- }
15
-
16
- if args.nil?
17
- args = {}
40
+ class << self
41
+ # Invokes an endpoint through HTTP using the given URL, bearer authentication, version, and arguments.
42
+ #
43
+ # @param url [String] The URL of the endpoint to invoke
44
+ # @param bearer_auth [String] The bearer authentication token
45
+ # @param version [String] The API version to use
46
+ # @param args [Hash] The arguments to send to the endpoint
47
+ #
48
+ # @return [Object] The response returned by the endpoint
49
+ #
50
+ def invoke(url, bearer_auth: nil, version: nil, args: {})
51
+ Request.execute(url, bearer_auth: bearer_auth, version: version, args: args)
18
52
  end
19
53
 
20
- if bearer_auth
21
- headers["Authorization"] = "Bearer #{bearer_auth}"
22
- end
54
+ # Creates a new Endpoint from a hash.
55
+ #
56
+ # @param endpoint [Hash] The endpoint hash
57
+ #
58
+ # @return [Endpoint] A new Endpoint instance
59
+ #
60
+ def from_hash(endpoint)
61
+ unless endpoint.is_a?(Hash)
62
+ return
63
+ end
64
+
65
+ unless endpoint["name"]
66
+ return
67
+ end
23
68
 
24
- if as_step
25
- return {
26
- url: url,
27
- headers: headers,
28
- body: args,
29
- }
69
+ new(
70
+ name: endpoint["name"],
71
+ returns: Utils.normalize_array_of_strings(endpoint["returns"]),
72
+ hints: Utils.normalize_array_of_strings(endpoint["hints"]),
73
+ flags: Utils.normalize_array_of_strings(endpoint["flags"]),
74
+ group: endpoint["group"],
75
+ docs: endpoint["docs"].to_s,
76
+ arguments: Argument.from_array(endpoint["arguments"]),
77
+ attributes: Attribute.from_array(endpoint["attributes"]),
78
+ errors: DocumentedError.from_array(endpoint["errors"]),
79
+ )
30
80
  end
31
81
 
32
- response = Excon.post(url,
33
- headers: headers,
34
- body: JSON.generate(args),
35
- )
36
-
37
- result = JSON.parse(response.body)
38
-
39
- if response.status == 400
40
- case result
41
- when Array
42
- if result.length == 3 && result[0].is_a?(String) && result[1].is_a?(String)
43
- code = result[0]
44
- message = result[1]
45
- details = result[2]
46
-
47
- raise WebFunction::Error.new(message, code: code, details: details)
48
- else
49
- raise WebFunction::Error.new("Bad request", details: result)
50
- end
51
- when String
52
- raise WebFunction::Error.new(result)
53
- else
54
- raise WebFunction::Error.new("Bad request", details: result)
82
+ # Creates a new Endpoint from an array of hashes. Uses {Endpoint#from_hash} under the hood.
83
+ #
84
+ # @param endpoints [Array<Hash>] The endpoint array of hashes
85
+ #
86
+ # @return [Array<Endpoint>] A new array of Endpoint instances
87
+ #
88
+ def from_array(endpoints)
89
+ Utils.normalize_array endpoints do |endpoint|
90
+ from_hash(endpoint)
55
91
  end
56
92
  end
93
+ end
57
94
 
58
- if response.status != 200
59
- raise WebFunction::Error.new("Unexpected status code [#{response.status}]")
95
+ # The {Client} used to invoke this endpoint. It is assigned when the endpoint is loaded from a package and is
96
+ # required by {#call}.
97
+ #
98
+ # @return [Client, nil]
99
+ #
100
+ attr_accessor :client
101
+
102
+ # The suffix for the endpoint URL, appended to the package's base URL to form the full endpoint URL. Endpoint names
103
+ # are unique within a package; overloading (two endpoints sharing the same name) is not permitted.
104
+ #
105
+ # @return [String]
106
+ #
107
+ attr_reader :name
108
+
109
+ # The JSON type(s) returned by the endpoint. A non-empty array whose entries are each one of:
110
+ #
111
+ # - object
112
+ # - array
113
+ # - string
114
+ # - number
115
+ # - boolean
116
+ # - null
117
+ #
118
+ # @return [Array<String>]
119
+ #
120
+ attr_reader :returns
121
+
122
+ # Hints for the endpoint's return value. Each hint's base JSON type matches one of the endpoint's {#returns} types,
123
+ # and at most one hint is supplied per base JSON type. See the [hints section][1] on the Web Function website for
124
+ # the complete list of allowed hints.
125
+ #
126
+ # @return [Array<String>]
127
+ #
128
+ # [1]: https://webfunction.org/package#hints
129
+ #
130
+ attr_reader :hints
131
+
132
+ # A name used to categorize or group similar endpoints together. This should be used by documentation tools to
133
+ # organize related endpoints.
134
+ #
135
+ # @return [String, nil]
136
+ #
137
+ attr_reader :group
138
+
139
+ # Documentation for the endpoint. It must be formatted as markdown.
140
+ #
141
+ # @return [String]
142
+ #
143
+ attr_reader :docs
144
+
145
+ # Invokes the endpoint through its assigned {#client}, passing the given arguments.
146
+ #
147
+ # @param args [Hash] The arguments to send to the endpoint.
148
+ #
149
+ # @raise [RuntimeError] If no client has been assigned to the endpoint.
150
+ #
151
+ # @return [Object] The decoded response returned by the endpoint.
152
+ #
153
+ def call(args = {})
154
+ unless client
155
+ raise "Client must be set to invoke an endpoint"
60
156
  end
61
157
 
62
- result
63
- rescue JSON::ParserError => e
64
- raise WebFunction::Error.new("Response cannot be parsed", details: {})
65
- end
66
-
67
- def name
68
- @endpoint["name"]
158
+ client.call(name, args)
69
159
  end
70
160
 
71
- def returns
72
- @endpoint["returns"]
161
+ # The list of errors specific to this endpoint. Clients SHOULD only refer to this list if the endpoint uses the
162
+ # `error_triple` flag. See the error specification for more information.
163
+ #
164
+ # @return [Array<DocumentedError>]
165
+ #
166
+ def errors
167
+ @errors.values
73
168
  end
74
169
 
75
- def flags
76
- @endpoint["flags"]
170
+ # Looks up a single endpoint error by its machine-readable code.
171
+ #
172
+ # @param code [String, Symbol] The error code to look up.
173
+ #
174
+ # @return [DocumentedError, nil] The matching error, or `nil` if none is found.
175
+ #
176
+ def error(code)
177
+ @errors[code.to_s]
77
178
  end
78
179
 
79
- def group
80
- @endpoint["group"]
180
+ # The attributes of the object returned by the endpoint. Relevant when the endpoint returns an `object`.
181
+ #
182
+ # @return [Array<Attribute>]
183
+ #
184
+ def attributes
185
+ @attributes.values
81
186
  end
82
187
 
83
- def docs
84
- @endpoint["docs"]
188
+ # Looks up a single returned attribute by name.
189
+ #
190
+ # @param name [String, Symbol] The name of the attribute to look up.
191
+ #
192
+ # @return [Attribute, nil] The matching attribute, or `nil` if none is found.
193
+ #
194
+ def attribute(name)
195
+ @attributes[name.to_s]
85
196
  end
86
197
 
198
+ # The arguments required by the endpoint. The array is empty when the endpoint requires no arguments.
199
+ #
200
+ # @return [Array<Argument>]
201
+ #
87
202
  def arguments
88
- unless @endpoint["arguments"].is_a?(Array)
89
- return []
90
- end
203
+ @arguments.values
204
+ end
91
205
 
92
- @endpoint["arguments"].map { |argument| Argument.new(argument) }
206
+ # Looks up a single argument by name.
207
+ #
208
+ # @param name [String, Symbol] The name of the argument to look up.
209
+ #
210
+ # @return [Argument, nil] The matching argument, or `nil` if none is found.
211
+ #
212
+ def argument(name)
213
+ @arguments[name.to_s]
93
214
  end
94
215
 
95
- def attributes
96
- unless @endpoint["attributes"].is_a?(Array)
97
- return []
98
- end
216
+ # Whether the endpoint requires authentication via a bearer token, i.e. whether it declares the `bearer_auth` flag.
217
+ #
218
+ # @return [Boolean]
219
+ #
220
+ def bearer_auth?
221
+ flag?("bearer_auth")
222
+ end
99
223
 
100
- @endpoint["attributes"].map { |attribute| Attribute.new(attribute) }
224
+ # Whether the endpoint returns a bearer token in its response, i.e. whether it declares the `capture_bearer` flag.
225
+ # See the authentication specification for more information.
226
+ #
227
+ # @return [Boolean]
228
+ #
229
+ def capture_bearer?
230
+ flag?("capture_bearer")
101
231
  end
102
232
 
103
- def errors
104
- unless @endpoint["errors"].is_a?(Array)
105
- return []
106
- end
233
+ # Whether the endpoint supports pagination, i.e. whether it declares the `paginated` flag.
234
+ #
235
+ # @return [Boolean]
236
+ #
237
+ def paginated?
238
+ flag?("paginated")
239
+ end
107
240
 
108
- @endpoint["errors"].map { |error| DocumentedError.new(error) }
241
+ # Whether the endpoint is intended for internal use and is not part of the public API, i.e. whether it declares the
242
+ # `private` flag. Documentation tooling SHOULD omit endpoints with this flag from generated or
243
+ # published documentation.
244
+ #
245
+ # @return [Boolean]
246
+ #
247
+ def private?
248
+ flag?("private")
109
249
  end
110
250
  end
111
251
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFunction
4
+ # A module that provides a flaggable interface. Flags are used to define the behavior of an object.
5
+ #
6
+ # @example
7
+ # class Endpoint
8
+ # include Flaggable
9
+ #
10
+ # def initialize(name:, flags: [])
11
+ # @name = name
12
+ # @flags = flags
13
+ # end
14
+ # end
15
+ #
16
+ # endpoint = Endpoint.new(name: "get_user", flags: ["private"])
17
+ # endpoint.flag?("private") # => true
18
+ # endpoint.flag?("public") # => false
19
+ #
20
+ module Flaggable
21
+ # List of flags. See the [available flags section][2] on the Web Function
22
+ # website for a complete list of flags available.
23
+ #
24
+ # @return [Array<String>]
25
+ #
26
+ # [2]: https://webfunction.org/package#available-flags
27
+ #
28
+ attr_reader :flags
29
+
30
+ # Whether the endpoint declares the given flag.
31
+ #
32
+ # @param flag [String] The flag to check for.
33
+ #
34
+ # @return [Boolean]
35
+ #
36
+ def flag?(flag)
37
+ @flags.include?(flag)
38
+ end
39
+ end
40
+ end
@@ -1,41 +1,156 @@
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: [])
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] }
11
29
  end
12
30
 
13
- def name
14
- @package["name"]
31
+ class << self
32
+ # Instantiate a new Package from a hash.
33
+ #
34
+ # @param package [Hash] The package hash
35
+ #
36
+ # @return [Package] A new Package instance
37
+ #
38
+ def from_hash(package)
39
+ new(
40
+ base_url: package["base_url"],
41
+ pipeline_url: package["pipeline_url"],
42
+ name: package["name"],
43
+ version: package["version"],
44
+ docs: package["docs"],
45
+ flags: Utils.normalize_array_of_strings(package["flags"]),
46
+ versions: Utils.normalize_array_of_strings(package["versions"]),
47
+ endpoints: Endpoint.from_array(package["endpoints"]),
48
+ errors: DocumentedError.from_array(package["errors"]),
49
+ )
50
+ end
15
51
  end
16
52
 
17
- def flags
18
- @package["flags"]
19
- end
53
+ # The base URL for the package. Endpoint URLs are formed by joining this base URL with each endpoint's name.
54
+ #
55
+ # This is required for the package to be valid and MUST use the HTTP or HTTPS scheme.
56
+ #
57
+ # @return [String]
58
+ #
59
+ attr_reader :base_url
60
+
61
+ # A function pipelining URL used to batch several endpoint invocations into a single request. See the pipelining
62
+ # specification for more information.
63
+ #
64
+ # @return [String, nil]
65
+ #
66
+ attr_reader :pipeline_url
67
+
68
+ # The name of the package.
69
+ #
70
+ # @return [String, nil]
71
+ #
72
+ attr_reader :name
73
+
74
+ # The version that this package describes. An opaque string.
75
+ #
76
+ # This MUST be present when the `versioned` flag is set. See the versioning specification for more information.
77
+ #
78
+ # @return [String, nil]
79
+ #
80
+ attr_reader :version
81
+
82
+ # Top-level documentation for the package. It must be formatted as markdown.
83
+ #
84
+ # @return [String]
85
+ #
86
+ attr_reader :docs
87
+
88
+ # The versions that are available. Each entry is an opaque string.
89
+ #
90
+ # This MUST be present when the `versioned` flag is set. See the versioning specification for more information.
91
+ #
92
+ # @return [Array<String>]
93
+ #
94
+ attr_reader :versions
20
95
 
21
- def docs
22
- @package["docs"]
96
+ # The {Pipeline} for this package, built from {#pipeline_url}, or `nil` when the package does not declare a
97
+ # pipeline URL.
98
+ #
99
+ # @return [Pipeline, nil]
100
+ #
101
+ def pipeline
102
+ unless pipeline_url
103
+ return
104
+ end
105
+
106
+ Pipeline.new(pipeline_url)
23
107
  end
24
108
 
109
+ # The endpoints declared by this package.
110
+ #
111
+ # @return [Array<Endpoint>]
112
+ #
25
113
  def endpoints
26
- unless @package["endpoints"].is_a?(Array)
27
- return []
28
- end
114
+ @endpoints.values
115
+ end
29
116
 
30
- @package["endpoints"].map { |endpoint| Endpoint.new(endpoint) }
117
+ # Looks up a single endpoint by name. Underscores in the given name are converted to hyphens so that Ruby-style
118
+ # names (e.g. `:find_user_by`) match the hyphenated endpoint names used in packages (e.g. `find-user-by`).
119
+ #
120
+ # @param name [String, Symbol] The name of the endpoint to look up.
121
+ #
122
+ # @return [Endpoint, nil] The matching endpoint, or `nil` if none is found.
123
+ #
124
+ def endpoint(name)
125
+ @endpoints[name.to_s.gsub("_", "-")]
31
126
  end
32
127
 
128
+ # The list of common errors that can be returned by any endpoint in this package. Only refer to this list if an
129
+ # endpoint uses the `error_triple` flag. See the error specification for more information.
130
+ #
131
+ # @return [Array<DocumentedError>]
132
+ #
33
133
  def errors
34
- unless @package["errors"].is_a?(Array)
35
- return []
36
- end
134
+ @errors.values
135
+ end
136
+
137
+ # Looks up a single common error by its machine-readable code.
138
+ #
139
+ # @param code [String, Symbol] The error code to look up.
140
+ #
141
+ # @return [DocumentedError, nil] The matching error, or `nil` if none is found.
142
+ #
143
+ def error(code)
144
+ @errors[code.to_s]
145
+ end
37
146
 
38
- @package["errors"].map { |error| DocumentedError.new(error) }
147
+ # Whether the package is versioned, i.e. whether it declares the `versioned` flag. A versioned package is selected
148
+ # using the `Api-Version` header.
149
+ #
150
+ # @return [Boolean]
151
+ #
152
+ def versioned?
153
+ flag?("versioned")
39
154
  end
40
155
  end
41
156
  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 = []