web_function 0.5.0 → 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,23 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebFunction
4
- # # Endpoint
5
- #
6
4
  # Represents an endpoint as described in a Web Function package.
7
5
  #
8
- # An endpoint defines an operation that can be performed via a Web Function API.
9
- # Endpoints declare their name, documentation, arguments (inputs), attributes (outputs), and
10
- # the possible errors that may occur when invoking them.
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:
11
10
  #
12
- # Endpoints are described as objects in each package under the `"endpoints"` key.
13
- # For more information, see:
14
11
  # - [Web Function package docs](https://webfunction.org/package)
15
12
  # - [Web Function endpoint docs](https://webfunction.org/endpoint)
16
13
  #
17
- # This class provides methods for accessing endpoint metadata (name, docs, arguments, attributes, errors)
18
- # and supports invocation via HTTP.
14
+ # This class provides methods for accessing endpoint metadata (name, docs, arguments, attributes, errors) and
15
+ # supports invocation via HTTP.
19
16
  #
20
17
  # Typical tasks include:
18
+ #
21
19
  # - Querying endpoint name or documentation
22
20
  # - Enumerating the arguments or attributes definitions
23
21
  # - Invoking the endpoint through HTTP using required inputs
@@ -25,209 +23,229 @@ module WebFunction
25
23
  # See: https://webfunction.org/endpoint for more details on endpoint structure and contract.
26
24
  #
27
25
  class Endpoint
28
- def initialize(endpoint)
29
- @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] }
30
38
  end
31
39
 
32
40
  class << self
33
- # ## HTTP client getter
41
+ # Invokes an endpoint through HTTP using the given URL, bearer authentication, version, and arguments.
34
42
  #
35
- # The HTTP client used to invoke the endpoint.
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
36
47
  #
37
- # @return [Proc]
48
+ # @return [Object] The response returned by the endpoint
38
49
  #
39
- def http_client
40
- @http_client ||= proc do |url, headers, body|
41
- response = Excon.post(url,
42
- headers: headers,
43
- body: body,
44
- )
45
- [response.status, response.body]
46
- end
50
+ def invoke(url, bearer_auth: nil, version: nil, args: {})
51
+ Request.execute(url, bearer_auth: bearer_auth, version: version, args: args)
47
52
  end
48
53
 
49
- # ## HTTP client setter
50
- #
51
- # Sets the HTTP client used to invoke the endpoint.
52
- #
53
- # To provide a custom HTTP client instead of the default (which uses Excon),
54
- # set this to any object responding to #call or a Proc/lambda.
55
- #
56
- # The contract is:
57
- # client.call(url, headers, body)
54
+ # Creates a new Endpoint from a hash.
58
55
  #
59
- # - url: [String] The full URL to post to (not just the hostname or path).
60
- # - headers:[Hash<String,String>] HTTP headers, e.g. { "Content-Type" => "application/json" }
61
- # - body: [String] The JSON body to post.
56
+ # @param endpoint [Hash] The endpoint hash
62
57
  #
63
- # The client must return a two-element Array: [status, body]:
64
- # - status: [Integer] HTTP status code (e.g. 200, 400, 500)
65
- # - body: [String] Raw response body as a string
58
+ # @return [Endpoint] A new Endpoint instance
66
59
  #
67
- # Example:
68
- # WebFunction::Endpoint.http_client = ->(url, headers, args) {
69
- # http_response = MyHTTP.post(url, headers: headers, body: JSON.generate(args))
70
- # [http_response.status, http_response.body]
71
- # }
72
- #
73
- # @param http_client [Proc,#call] The new HTTP client to use.
74
- #
75
- attr_writer :http_client
76
-
77
- def step(url, bearer_auth: nil, args: {})
78
- headers = {
79
- "Content-Type": "application/json",
80
- "Accept": "application/json",
81
- "User-Agent": "webfunction/#{WebFunction::VERSION}",
82
- }
83
-
84
- if args.nil?
85
- args = {}
60
+ def from_hash(endpoint)
61
+ unless endpoint.is_a?(Hash)
62
+ return
86
63
  end
87
64
 
88
- if bearer_auth
89
- headers["Authorization"] = "Bearer #{bearer_auth}"
65
+ unless endpoint["name"]
66
+ return
90
67
  end
91
68
 
92
- {
93
- url: url,
94
- headers: headers,
95
- body: args,
96
- }
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
+ )
97
80
  end
98
81
 
99
- def invoke(url, bearer_auth: nil, args: {})
100
- headers = {
101
- "Content-Type": "application/json",
102
- "Accept": "application/json",
103
- "User-Agent": "webfunction/#{WebFunction::VERSION}",
104
- }
105
-
106
- if args.nil?
107
- args = {}
108
- end
109
-
110
- if bearer_auth
111
- headers["Authorization"] = "Bearer #{bearer_auth}"
112
- end
113
-
114
- status, body = http_client.call(url, headers, JSON.generate(args))
115
-
116
- unless [200, 400].include?(status)
117
- raise WebFunction::UnexpectedStatusCodeError.new("Unexpected status code (#{status})",
118
- details: {
119
- status_code: status,
120
- raw_body: body,
121
- },
122
- )
123
- end
124
-
125
- begin
126
- result = JSON.parse(body)
127
- rescue JSON::ParserError => e
128
- raise WebFunction::JsonParseError.new(e.message,
129
- details: {
130
- status_code: status,
131
- raw_body: body,
132
- original_exception: e,
133
- },
134
- )
135
- end
136
-
137
- if status == 400
138
- code = "BAD_REQUEST"
139
- message = "Bad request"
140
- details = { body: result }
141
-
142
- if result.is_a?(Array) && result.length == 3 && result[0].is_a?(String) && result[1].is_a?(String)
143
- code = result[0]
144
- message = result[1]
145
- details = result[2]
146
- end
147
-
148
- raise WebFunction::BadRequestError.new(message, code: code, details: details)
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)
149
91
  end
150
-
151
- result
152
92
  end
153
93
  end
154
94
 
155
- def name
156
- @endpoint["name"]
157
- end
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"
156
+ end
158
157
 
159
- def returns
160
- [*@endpoint["returns"]].map { |type| type.to_s }
158
+ client.call(name, args)
161
159
  end
162
160
 
163
- def hints
164
- [*@endpoint["hints"]].map { |hint| hint.to_s }
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
165
168
  end
166
169
 
167
- def flags
168
- [*@endpoint["flags"]].map { |flag| flag.to_s }
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]
169
178
  end
170
179
 
171
- def group
172
- @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
173
186
  end
174
187
 
175
- def docs
176
- @endpoint["docs"].to_s
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]
177
196
  end
178
197
 
198
+ # The arguments required by the endpoint. The array is empty when the endpoint requires no arguments.
199
+ #
200
+ # @return [Array<Argument>]
201
+ #
179
202
  def arguments
180
- unless @endpoint["arguments"].is_a?(Array)
181
- return []
182
- end
183
-
184
- @endpoint["arguments"].map do |argument|
185
- unless argument.is_a?(Hash)
186
- next
187
- end
188
-
189
- unless argument["name"]
190
- next
191
- end
192
-
193
- Argument.new(argument)
194
- end
203
+ @arguments.values
195
204
  end
196
205
 
197
- def attributes
198
- unless @endpoint["attributes"].is_a?(Array)
199
- return []
200
- end
201
-
202
- @endpoint["attributes"].map do |attribute|
203
- unless attribute.is_a?(Hash)
204
- next
205
- end
206
-
207
- unless attribute["name"]
208
- next
209
- end
210
-
211
- Attribute.new(attribute)
212
- end
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]
213
214
  end
214
215
 
215
- def errors
216
- unless @endpoint["errors"].is_a?(Array)
217
- return []
218
- 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
219
223
 
220
- @endpoint["errors"].map do |error|
221
- unless error.is_a?(Hash)
222
- next
223
- end
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")
231
+ end
224
232
 
225
- unless error["code"]
226
- next
227
- 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
228
240
 
229
- DocumentedError.new(error)
230
- end
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")
231
249
  end
232
250
  end
233
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,67 +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
- unless @package["flags"].is_a?(Array)
19
- return []
20
- 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
21
60
 
22
- @package["flags"].each do |flag|
23
- flag.to_s
24
- end
25
- end
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
26
67
 
27
- def docs
28
- @package["docs"].to_s
29
- end
68
+ # The name of the package.
69
+ #
70
+ # @return [String, nil]
71
+ #
72
+ attr_reader :name
30
73
 
31
- def endpoints
32
- unless @package["endpoints"].is_a?(Array)
33
- return []
34
- end
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
35
81
 
36
- @package["endpoints"].map do |endpoint|
37
- unless endpoint.is_a?(Hash)
38
- next
39
- end
82
+ # Top-level documentation for the package. It must be formatted as markdown.
83
+ #
84
+ # @return [String]
85
+ #
86
+ attr_reader :docs
40
87
 
41
- unless endpoint["name"]
42
- next
43
- end
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
44
95
 
45
- Endpoint.new(endpoint)
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
46
104
  end
105
+
106
+ Pipeline.new(pipeline_url)
47
107
  end
48
108
 
49
- def errors
50
- unless @package["errors"].is_a?(Array)
51
- return []
52
- end
109
+ # The endpoints declared by this package.
110
+ #
111
+ # @return [Array<Endpoint>]
112
+ #
113
+ def endpoints
114
+ @endpoints.values
115
+ end
53
116
 
54
- @package["errors"].map do |error|
55
- unless error.is_a?(Hash)
56
- next
57
- end
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("_", "-")]
126
+ end
58
127
 
59
- unless error["code"]
60
- next
61
- end
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
+ #
133
+ def errors
134
+ @errors.values
135
+ end
62
136
 
63
- DocumentedError.new(error)
64
- end
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
146
+
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")
65
154
  end
66
155
  end
67
156
  end