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,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
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
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
+ #
15
+ class Package
16
+ include Flaggable
17
+
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] }
30
+ end
31
+
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
53
+ end
54
+
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
83
+
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
106
+ end
107
+
108
+ Pipeline.new(pipeline_url)
109
+ end
110
+
111
+ # The endpoints declared by this package.
112
+ #
113
+ # @return [Array<Endpoint>]
114
+ #
115
+ def endpoints
116
+ @endpoints.values
117
+ end
118
+
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
129
+
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
138
+
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]
147
+ end
148
+
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
157
+
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]
175
+
176
+ unless object
177
+ return
178
+ end
179
+
180
+ if object.properties(context).empty?
181
+ return
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")
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFunction
4
+ # A page of results from a paginated endpoint.
5
+ #
6
+ # Paginated responses follow the Web Function pagination contract: a JSON object with
7
+ # `page`, `next`, and `previous` keys. {Request} detects this shape and returns a {Page}
8
+ # instead of a bare Hash. The `next` and `previous` values are opaque request bodies —
9
+ # call {#next_page} or {#previous_page} to fetch the adjacent page; do not construct or modify them.
10
+ #
11
+ # @example
12
+ # page = WebFunction::Request.execute(
13
+ # "https://api.example.com/list-people",
14
+ # args: { filters: { first_name: "Joe" } },
15
+ # )
16
+ # page.page # => [{ "person_id" => "...", ... }, ...]
17
+ # page.next? # => true
18
+ # next_page = page.next_page
19
+ # next_page.previous? # => true
20
+ #
21
+ # See the [pagination specification][0] for the full contract.
22
+ #
23
+ # [0]: https://webfunction.org/pagination
24
+ #
25
+ class Page
26
+ include Enumerable
27
+
28
+ def initialize(page:, next_body:, previous_body:, url:, bearer_auth: nil, version: nil)
29
+ @page = page
30
+ @next_body = next_body
31
+ @previous_body = previous_body
32
+ @url = url
33
+ @bearer_auth = bearer_auth
34
+ @version = version
35
+ end
36
+
37
+ class << self
38
+ # Whether +response+ matches the paginated response shape.
39
+ #
40
+ # @param response [Object] A parsed JSON response
41
+ #
42
+ # @return [Boolean]
43
+ #
44
+ def paginated?(response)
45
+ response.is_a?(Hash) &&
46
+ response.key?("page") &&
47
+ response.key?("next") &&
48
+ response.key?("previous") &&
49
+ response["page"].is_a?(Array) &&
50
+ (response["next"].nil? || response["next"].is_a?(Hash)) &&
51
+ (response["previous"].nil? || response["previous"].is_a?(Hash))
52
+ end
53
+
54
+ # Wraps a paginated response in a {Page}, or returns +response+ unchanged.
55
+ #
56
+ # @param response [Object] A parsed JSON response
57
+ # @param request [Request] The request that produced the response
58
+ #
59
+ # @return [Page, Object]
60
+ #
61
+ def wrap(response, request:)
62
+ return response unless paginated?(response)
63
+
64
+ new(
65
+ page: response["page"],
66
+ next_body: response["next"],
67
+ previous_body: response["previous"],
68
+ url: request.url,
69
+ bearer_auth: request.bearer_auth,
70
+ version: request.version,
71
+ )
72
+ end
73
+ end
74
+
75
+ # The items on the current page.
76
+ #
77
+ # @return [Array]
78
+ #
79
+ attr_reader :page
80
+
81
+ # Whether a next page is available.
82
+ #
83
+ # @return [Boolean]
84
+ #
85
+ def next?
86
+ !@next_body.nil?
87
+ end
88
+
89
+ # Whether a previous page is available.
90
+ #
91
+ # @return [Boolean]
92
+ #
93
+ def previous?
94
+ !@previous_body.nil?
95
+ end
96
+
97
+ # Fetches the next page by posting the opaque `next` body to the same endpoint.
98
+ #
99
+ # @return [Page, nil] The next page, or `nil` if there is none
100
+ #
101
+ def next_page
102
+ fetch(@next_body)
103
+ end
104
+
105
+ # Fetches the previous page by posting the opaque `previous` body to the same endpoint.
106
+ #
107
+ # @return [Page, nil] The previous page, or `nil` if there is none
108
+ #
109
+ def previous_page
110
+ fetch(@previous_body)
111
+ end
112
+
113
+ # Iterates over the items on the current page.
114
+ #
115
+ # @yield [Object] Each item in {#page}
116
+ #
117
+ # @return [Enumerator, Page]
118
+ #
119
+ def each(&block)
120
+ return enum_for(:each) unless block
121
+
122
+ @page.each(&block)
123
+ self
124
+ end
125
+
126
+ private
127
+
128
+ def fetch(body)
129
+ return nil if body.nil?
130
+
131
+ Request.execute(@url, bearer_auth: @bearer_auth, version: @version, args: body)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
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
+ #
12
+ class Pipeline
13
+ def initialize(url)
14
+ @url = url
15
+ @steps = []
16
+ @promises = []
17
+ end
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
+ #
25
+ def add_step(step)
26
+ n = @promises.count
27
+ promise = Promise.new(self, "$[#{n}]")
28
+
29
+ @steps << step
30
+ @promises << promise
31
+
32
+ promise
33
+ end
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
+ #
41
+ def execute(returns: :all)
42
+ case returns
43
+ when :all
44
+ responses = Request.execute(@url, args: {
45
+ steps: @steps,
46
+ returns: "$",
47
+ },
48
+ )
49
+
50
+ responses.each_with_index do |response, index|
51
+ @promises[index].value = response
52
+ end
53
+
54
+ reset!
55
+
56
+ responses
57
+ when :last
58
+ response = Request.execute(@url, args: {
59
+ steps: @steps,
60
+ returns: "$[-1:]",
61
+ },
62
+ )
63
+
64
+ @promises.last.value = response
65
+
66
+ reset!
67
+
68
+ response
69
+ else
70
+ response = Request.execute(@url, args: {
71
+ steps: @steps,
72
+ returns: returns,
73
+ },
74
+ )
75
+
76
+ reset!
77
+
78
+ response
79
+ end
80
+ end
81
+
82
+ # Resets the pipeline.
83
+ #
84
+ # @return [void]
85
+ #
86
+ def reset!
87
+ @steps = []
88
+ @promises = []
89
+ end
90
+ end
91
+ end