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,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,221 @@ 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:, flags: [], group: nil, docs: nil, arguments: [], attributes: [], errors: [])
29
+ @name = name
30
+ @returns = Type.parse(returns)
31
+ @flags = flags
32
+ @group = group
33
+ @docs = docs
34
+ @arguments = arguments.to_h { |a| [a.name, a] }
35
+ @attributes = attributes.to_h { |a| [a.name, a] }
36
+ @errors = errors.to_h { |e| [e.code, e] }
30
37
  end
31
38
 
32
39
  class << self
33
- # ## HTTP client getter
40
+ # Invokes an endpoint through HTTP using the given URL, bearer authentication, version, and arguments.
34
41
  #
35
- # The HTTP client used to invoke the endpoint.
42
+ # @param url [String] The URL of the endpoint to invoke
43
+ # @param bearer_auth [String] The bearer authentication token
44
+ # @param version [String] The API version to use
45
+ # @param args [Hash] The arguments to send to the endpoint
36
46
  #
37
- # @return [Proc]
47
+ # @return [Object] The response returned by the endpoint
38
48
  #
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
49
+ def invoke(url, bearer_auth: nil, version: nil, args: {})
50
+ Request.execute(url, bearer_auth: bearer_auth, version: version, args: args)
47
51
  end
48
52
 
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)
53
+ # Creates a new Endpoint from a hash.
58
54
  #
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.
55
+ # @param endpoint [Hash] The endpoint hash
62
56
  #
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
57
+ # @return [Endpoint] A new Endpoint instance
66
58
  #
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 = {}
59
+ def from_hash(endpoint)
60
+ unless endpoint.is_a?(Hash)
61
+ return
86
62
  end
87
63
 
88
- if bearer_auth
89
- headers["Authorization"] = "Bearer #{bearer_auth}"
64
+ unless endpoint["name"]
65
+ return
90
66
  end
91
67
 
92
- {
93
- url: url,
94
- headers: headers,
95
- body: args,
96
- }
97
- end
98
-
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 = {}
68
+ unless endpoint["returns"]
69
+ return
108
70
  end
109
71
 
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
72
+ new(
73
+ name: endpoint["name"],
74
+ returns: endpoint["returns"],
75
+ flags: Utils.normalize_array_of_strings(endpoint["flags"]),
76
+ group: endpoint["group"],
77
+ docs: endpoint["docs"].to_s,
78
+ arguments: Argument.from_array(endpoint["arguments"]),
79
+ attributes: Attribute.from_array(endpoint["attributes"]),
80
+ errors: DocumentedError.from_array(endpoint["errors"]),
81
+ )
82
+ end
147
83
 
148
- raise WebFunction::BadRequestError.new(message, code: code, details: details)
84
+ # Creates a new Endpoint from an array of hashes. Uses {Endpoint#from_hash} under the hood.
85
+ #
86
+ # @param endpoints [Array<Hash>] The endpoint array of hashes
87
+ #
88
+ # @return [Array<Endpoint>] A new array of Endpoint instances
89
+ #
90
+ def from_array(endpoints)
91
+ Utils.normalize_array endpoints do |endpoint|
92
+ from_hash(endpoint)
149
93
  end
150
-
151
- result
152
94
  end
153
95
  end
154
96
 
155
- def name
156
- @endpoint["name"]
157
- end
97
+ # The {Client} used to invoke this endpoint. It is assigned when the endpoint is loaded from a package and is
98
+ # required by {#call}.
99
+ #
100
+ # @return [Client, nil]
101
+ #
102
+ attr_accessor :client
103
+
104
+ # The suffix for the endpoint URL, appended to the package's base URL to form the full endpoint URL. Endpoint names
105
+ # are unique within a package; overloading (two endpoints sharing the same name) is not permitted.
106
+ #
107
+ # @return [String]
108
+ #
109
+ attr_reader :name
110
+
111
+ # The JSON type(s) returned by the endpoint. A non-empty array whose entries are each one of:
112
+ #
113
+ # - object
114
+ # - array
115
+ # - string
116
+ # - number
117
+ # - boolean
118
+ # - null
119
+ #
120
+ # @return [Array<String>]
121
+ #
122
+ attr_reader :returns
123
+
124
+ # A name used to categorize or group similar endpoints together. This should be used by documentation tools to
125
+ # organize related endpoints.
126
+ #
127
+ # @return [String, nil]
128
+ #
129
+ attr_reader :group
130
+
131
+ # Documentation for the endpoint. It must be formatted as markdown.
132
+ #
133
+ # @return [String]
134
+ #
135
+ attr_reader :docs
136
+
137
+ # Invokes the endpoint through its assigned {#client}, passing the given arguments.
138
+ #
139
+ # @param args [Hash] The arguments to send to the endpoint.
140
+ #
141
+ # @raise [RuntimeError] If no client has been assigned to the endpoint.
142
+ #
143
+ # @return [Object] The decoded response returned by the endpoint.
144
+ #
145
+ def call(args = {})
146
+ unless client
147
+ raise "Client must be set to invoke an endpoint"
148
+ end
158
149
 
159
- def returns
160
- [*@endpoint["returns"]].map { |type| type.to_s }
150
+ client.call(name, args)
161
151
  end
162
152
 
163
- def hints
164
- [*@endpoint["hints"]].map { |hint| hint.to_s }
153
+ # The list of errors specific to this endpoint. Clients SHOULD only refer to this list if the endpoint uses the
154
+ # `error_triple` flag. See the error specification for more information.
155
+ #
156
+ # @return [Array<DocumentedError>]
157
+ #
158
+ def errors
159
+ @errors.values
165
160
  end
166
161
 
167
- def flags
168
- [*@endpoint["flags"]].map { |flag| flag.to_s }
162
+ # Looks up a single endpoint error by its machine-readable code.
163
+ #
164
+ # @param code [String, Symbol] The error code to look up.
165
+ #
166
+ # @return [DocumentedError, nil] The matching error, or `nil` if none is found.
167
+ #
168
+ def error(code)
169
+ @errors[code.to_s]
169
170
  end
170
171
 
171
- def group
172
- @endpoint["group"]
172
+ # The attributes of the object returned by the endpoint. Relevant when the endpoint returns an `object`.
173
+ #
174
+ # @return [Array<Attribute>]
175
+ #
176
+ def attributes
177
+ @attributes.values
173
178
  end
174
179
 
175
- def docs
176
- @endpoint["docs"].to_s
180
+ # Looks up a single returned attribute by name.
181
+ #
182
+ # @param name [String, Symbol] The name of the attribute to look up.
183
+ #
184
+ # @return [Attribute, nil] The matching attribute, or `nil` if none is found.
185
+ #
186
+ def attribute(name)
187
+ @attributes[name.to_s]
177
188
  end
178
189
 
190
+ # The arguments required by the endpoint. The array is empty when the endpoint requires no arguments.
191
+ #
192
+ # @return [Array<Argument>]
193
+ #
179
194
  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
195
+ @arguments.values
195
196
  end
196
197
 
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
198
+ # Looks up a single argument by name.
199
+ #
200
+ # @param name [String, Symbol] The name of the argument to look up.
201
+ #
202
+ # @return [Argument, nil] The matching argument, or `nil` if none is found.
203
+ #
204
+ def argument(name)
205
+ @arguments[name.to_s]
213
206
  end
214
207
 
215
- def errors
216
- unless @endpoint["errors"].is_a?(Array)
217
- return []
218
- end
208
+ # Whether the endpoint requires authentication via a bearer token, i.e. whether it declares the `bearer_auth` flag.
209
+ #
210
+ # @return [Boolean]
211
+ #
212
+ def bearer_auth?
213
+ flag?("bearer_auth")
214
+ end
219
215
 
220
- @endpoint["errors"].map do |error|
221
- unless error.is_a?(Hash)
222
- next
223
- end
216
+ # Whether the endpoint returns a bearer token in its response, i.e. whether it declares the `capture_bearer` flag.
217
+ # See the authentication specification for more information.
218
+ #
219
+ # @return [Boolean]
220
+ #
221
+ def capture_bearer?
222
+ flag?("capture_bearer")
223
+ end
224
224
 
225
- unless error["code"]
226
- next
227
- end
225
+ # Whether the endpoint supports pagination, i.e. whether it declares the `paginated` flag.
226
+ #
227
+ # @return [Boolean]
228
+ #
229
+ def paginated?
230
+ flag?("paginated")
231
+ end
228
232
 
229
- DocumentedError.new(error)
230
- end
233
+ # Whether the endpoint is intended for internal use and is not part of the public API, i.e. whether it declares the
234
+ # `private` flag. Documentation tooling SHOULD omit endpoints with this flag from generated or
235
+ # published documentation.
236
+ #
237
+ # @return [Boolean]
238
+ #
239
+ def private?
240
+ flag?("private")
231
241
  end
232
242
  end
233
243
  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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFunction
4
+ # Represents a named object definition as described in a Web Function package.
5
+ #
6
+ # Objects are declared in a package under the `"objects"` key and can be referenced as a refined object type
7
+ # (`object.<name>`) anywhere a type is expected. An `object.` reference appears in one of two contexts, which
8
+ # determines which set of properties applies:
9
+ #
10
+ # - Argument context — the object is referenced as an argument's `type`. Its {#arguments} describe its properties.
11
+ # - Attribute context — the object is referenced as an endpoint's `returns` or as an attribute's `type`. Its
12
+ # {#attributes} describe its properties.
13
+ #
14
+ # Because an object MAY be referenced in both contexts within the same package, it MAY define both `arguments` and
15
+ # `attributes`; each set is used only in its matching context.
16
+ #
17
+ # It is named `ObjectSchema` rather than `Object` to avoid clashing with Ruby's built-in `::Object`.
18
+ #
19
+ # See the [object definition documentation][0] on the Web Function website for more details.
20
+ #
21
+ # [0]: https://webfunction.org/package#object-definition
22
+ #
23
+ class ObjectSchema
24
+ # The contexts in which an object may be referenced. Each maps to the member set that applies in that context.
25
+ #
26
+ # @return [Array<Symbol>]
27
+ #
28
+ CONTEXTS = %i[arguments attributes].freeze
29
+
30
+ def initialize(name:, arguments: [], attributes: [])
31
+ @name = name
32
+ @arguments = arguments.to_h { |a| [a.name, a] }
33
+ @attributes = attributes.to_h { |a| [a.name, a] }
34
+ end
35
+
36
+ class << self
37
+ # Creates a new ObjectSchema from a hash. Typically coming from a {Package}.
38
+ #
39
+ # @param object [Hash] The object hash
40
+ #
41
+ # @return [ObjectSchema, nil] A new ObjectSchema instance, or `nil` if the hash is invalid.
42
+ #
43
+ def from_hash(object)
44
+ unless object.is_a?(Hash)
45
+ return
46
+ end
47
+
48
+ unless object["name"]
49
+ return
50
+ end
51
+
52
+ new(
53
+ name: object["name"],
54
+ arguments: Argument.from_array(object["arguments"]),
55
+ attributes: Attribute.from_array(object["attributes"]),
56
+ )
57
+ end
58
+
59
+ # Creates a new ObjectSchema from an array of hashes. Typically coming from a {Package}. Uses
60
+ # {ObjectSchema#from_hash} under the hood.
61
+ #
62
+ # @param objects [Array<Hash>] The object array of hashes
63
+ #
64
+ # @return [Array<ObjectSchema>] A new array of ObjectSchema instances
65
+ #
66
+ def from_array(objects)
67
+ Utils.normalize_array objects do |object|
68
+ from_hash(object)
69
+ end
70
+ end
71
+ end
72
+
73
+ # The name of the object. It is referenced as a refined object type (`object.<name>`) and is unique within a
74
+ # package.
75
+ #
76
+ # @return [String]
77
+ #
78
+ attr_reader :name
79
+
80
+ # The object's properties when it is referenced in an argument context.
81
+ #
82
+ # @return [Array<Argument>]
83
+ #
84
+ def arguments
85
+ @arguments.values
86
+ end
87
+
88
+ # Looks up a single argument member by name.
89
+ #
90
+ # @param name [String, Symbol] The name of the argument to look up.
91
+ #
92
+ # @return [Argument, nil] The matching argument, or `nil` if none is found.
93
+ #
94
+ def argument(name)
95
+ @arguments[name.to_s]
96
+ end
97
+
98
+ # The object's properties when it is referenced in an attribute context.
99
+ #
100
+ # @return [Array<Attribute>]
101
+ #
102
+ def attributes
103
+ @attributes.values
104
+ end
105
+
106
+ # Looks up a single attribute member by name.
107
+ #
108
+ # @param name [String, Symbol] The name of the attribute to look up.
109
+ #
110
+ # @return [Attribute, nil] The matching attribute, or `nil` if none is found.
111
+ #
112
+ def attribute(name)
113
+ @attributes[name.to_s]
114
+ end
115
+
116
+ # The object's properties for the given context.
117
+ #
118
+ # @param context [Symbol] The context to resolve properties for. One of {CONTEXTS}.
119
+ #
120
+ # @raise [ArgumentError] If the context is not one of {CONTEXTS}.
121
+ #
122
+ # @return [Array<Argument>, Array<Attribute>] The properties that apply in the given context.
123
+ #
124
+ def properties(context)
125
+ case context
126
+ when :arguments
127
+ arguments
128
+ when :attributes
129
+ attributes
130
+ else
131
+ raise ArgumentError, "context must be one of #{CONTEXTS.inspect}, got #{context.inspect}"
132
+ end
133
+ end
134
+ end
135
+ end