servicestack 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d4c81df4c0df47d185c8f942f3da2e61983d149eb74ac128e03005eb9af30b3
4
+ data.tar.gz: 15f64c2c49f10e6803ff8a722edb1faeb20c670d99350103848390ab291239a9
5
+ SHA512:
6
+ metadata.gz: 204756127fbd7b57d41c521f7f65824ba804c1bbd5c3487fdb9b4561eaba3c91847fe525c96e59092c2058f178948c44e105eb3a2bd21480ce662d719befa220
7
+ data.tar.gz: 6d29f98815d56c1dd66eb7e8982226b68288d20799569954b83a55c098aa9a2f5e2cdd5629c8d1bf8f6aa09313649b805ef701c20490a0afa270c8221c0889b8
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0]
6
+
7
+ ### Added
8
+
9
+ - `ServiceStack::JsonServiceClient` for consuming ServiceStack APIs with
10
+ generated typed DTOs
11
+ - `ServiceStack::DTO` conversions driven by the wire name and Type metadata
12
+ generated DTOs declare, so nested DTOs, Dates and collections round-trip
13
+ - Structured `ResponseStatus` errors in `WebServiceException`, incl. field errors
14
+ - `api` results that return errors instead of raising
15
+ - Auth with Basic Auth, API Keys, Bearer Tokens, Refresh Tokens and Session Cookies
16
+ - Batched (`send_all`), one-way (`publish`) and custom URL Requests
17
+ - Built-in ServiceStack DTOs referenced by generated DTOs (`ResponseStatus`,
18
+ `QueryBase`, `QueryResponse`, `Authenticate`, ...)
19
+
20
+ [0.1.0]: https://github.com/ServiceStack/servicestack-ruby/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) ServiceStack
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # servicestack-ruby
2
+
3
+ Typed Ruby Client Library for consuming [ServiceStack](https://servicestack.net) APIs.
4
+
5
+ - Typed Request/Response DTOs, generated from any ServiceStack API
6
+ - Response Type, route and HTTP Method resolved from the Request DTO
7
+ - Structured `ResponseStatus` errors with field validation errors
8
+ - Auth with Basic Auth, API Keys, JWT Bearer Tokens, Refresh Tokens and Session Cookies
9
+ - Batched Requests, one-way Requests and custom URLs
10
+ - Zero dependencies, only the Ruby standard library
11
+
12
+ Requires Ruby 3.0+.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ gem install servicestack
18
+ ```
19
+
20
+ Or in your `Gemfile`:
21
+
22
+ ```ruby
23
+ gem 'servicestack'
24
+ ```
25
+
26
+ ## Generate Typed DTOs
27
+
28
+ Generate the Ruby DTOs of any ServiceStack API with the [get-dtos](https://www.npmjs.com/package/get-dtos) tool:
29
+
30
+ ```bash
31
+ npx get-dtos ruby https://blazor-vue.web-templates.io
32
+ ```
33
+
34
+ Which downloads a `dtos.rb` containing the typed DTOs of the remote API:
35
+
36
+ ```ruby
37
+ require 'json'
38
+ require 'servicestack'
39
+
40
+ # @Route("/hello/{Name}")
41
+ class Hello
42
+ include ServiceStack::DTO
43
+
44
+ # @return [String]
45
+ attr_accessor :name
46
+
47
+ def self.properties
48
+ {
49
+ name: { name: 'name' },
50
+ }
51
+ end
52
+
53
+ def response_type() = HelloResponse
54
+ def get_type_name() = 'Hello'
55
+ def get_method() = 'GET'
56
+ end
57
+ ```
58
+
59
+ The generated `response_type`, `get_type_name` and `get_method` are what let the
60
+ client resolve each API's Response Type, route and HTTP Method, whilst
61
+ `self.properties` declares the wire name and Type of each property so nested
62
+ DTOs, Dates and collections round-trip correctly.
63
+
64
+ ## Usage
65
+
66
+ ```ruby
67
+ require 'servicestack'
68
+ require_relative 'dtos'
69
+
70
+ client = ServiceStack::JsonServiceClient.new('https://blazor-vue.web-templates.io')
71
+
72
+ res = client.send(Hello.new(name: 'World')) # res is a HelloResponse
73
+ puts res.result
74
+ ```
75
+
76
+ `send` uses the HTTP Method the API is annotated with, use `get`, `post`, `put`,
77
+ `patch` or `delete` to send a Request DTO with a specific HTTP Method:
78
+
79
+ ```ruby
80
+ res = client.post(Hello.new(name: 'World'))
81
+ ```
82
+
83
+ APIs that don't return a Response Body are sent with `send_void`:
84
+
85
+ ```ruby
86
+ client.send_void(DeleteBooking.new(id: 1))
87
+ ```
88
+
89
+ > `JsonServiceClient#send` overrides `Object#send`. Use `__send__` for Ruby's
90
+ > dynamic dispatch, or `send_dto` if you prefer an unambiguous name.
91
+
92
+ ### AutoQuery
93
+
94
+ AutoQuery APIs return a typed `QueryResponse`, with the query params of their
95
+ base type inherited by the Request DTO:
96
+
97
+ ```ruby
98
+ res = client.send(QueryBookings.new(take: 5, order_by_desc: 'id'))
99
+
100
+ res.results.each do |booking| # booking is a Booking
101
+ puts "#{booking.id} #{booking.name}"
102
+ end
103
+ ```
104
+
105
+ ### Error Handling
106
+
107
+ Failed API Requests raise a `WebServiceException` containing the HTTP Status
108
+ Code and the API's structured `ResponseStatus`:
109
+
110
+ ```ruby
111
+ begin
112
+ client.send(CreateBooking.new)
113
+ rescue ServiceStack::WebServiceException => e
114
+ puts e.status_code # 400
115
+ puts e.error_code # "NotEmpty"
116
+ puts e.error_message # "'Name' must not be empty."
117
+ puts e.field_error('Name') # "'Name' must not be empty."
118
+ puts e.unauthorized? # false
119
+ end
120
+ ```
121
+
122
+ Alternatively `api` returns errors in its result instead of raising:
123
+
124
+ ```ruby
125
+ api = client.api(CreateBooking.new)
126
+ if api.failed?
127
+ puts api.error_code, api.field_error('Name')
128
+ else
129
+ puts api.response.id
130
+ end
131
+ ```
132
+
133
+ Redirects aren't followed, so Services that redirect to a HTML sign in page
134
+ raise a `WebServiceException` with a `Redirect` ErrorCode instead of returning
135
+ an empty Response.
136
+
137
+ ### Authentication
138
+
139
+ API Keys and JWTs are sent in the Bearer Token Authorization header:
140
+
141
+ ```ruby
142
+ client.set_bearer_token('ak-87949de37e894627a9f6173154e7cafa')
143
+ ```
144
+
145
+ HTTP Basic Auth credentials:
146
+
147
+ ```ruby
148
+ client.set_credentials('username', 'password')
149
+ ```
150
+
151
+ Sign in with ServiceStack's Authenticate API, which retains the Session Cookies
152
+ the Server returns and uses any Bearer Token it issues:
153
+
154
+ ```ruby
155
+ auth = client.authenticate('username', 'password')
156
+ ```
157
+
158
+ When a Refresh Token is configured, expired Bearer Tokens are transparently
159
+ refreshed and the failed Request retried:
160
+
161
+ ```ruby
162
+ client.set_refresh_token(refresh_token)
163
+ ```
164
+
165
+ ### Batched Requests
166
+
167
+ ```ruby
168
+ responses = client.send_all([Hello.new(name: 'A'), Hello.new(name: 'B')])
169
+ ```
170
+
171
+ Or send a Request to a one-way endpoint that ignores its Response:
172
+
173
+ ```ruby
174
+ client.publish(Hello.new(name: 'World'))
175
+ ```
176
+
177
+ ### Custom URLs
178
+
179
+ ```ruby
180
+ res = client.get_url('/hello/World', response_as: HelloResponse)
181
+ res = client.post_url('/hello', body: Hello.new(name: 'World'), response_as: HelloResponse)
182
+ csv = client.send_url_string('/api/QueryBookings.csv')
183
+ ```
184
+
185
+ ### Client Configuration
186
+
187
+ ```ruby
188
+ client.set_header('X-Custom', 'Value')
189
+ client.timeout = 30
190
+ client.set_base_path('') # use the /json/reply pre-defined routes
191
+
192
+ # Inspect or modify each Request and Response
193
+ client.request_filter = ->(req) { puts req.path }
194
+ client.response_filter = ->(res) { puts res.code }
195
+ ```
196
+
197
+ ## Tests
198
+
199
+ ```bash
200
+ rake test # unit tests
201
+ rake test:integration # integration tests against test.servicestack.net
202
+ ```
203
+
204
+ ## License
205
+
206
+ BSD-3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module ServiceStack
6
+ # Included by generated DTOs to convert them to and from the JSON their APIs
7
+ # are serialized with.
8
+ #
9
+ # Generated DTOs declare the wire name and Type of each property, which is
10
+ # what lets nested DTOs, Dates and collections round-trip correctly:
11
+ #
12
+ # class Booking
13
+ # include ServiceStack::DTO
14
+ # attr_accessor :id, :booking_start_date, :discount
15
+ #
16
+ # def self.properties
17
+ # {
18
+ # id: { name: 'id' },
19
+ # booking_start_date: { name: 'bookingStartDate', type: DateTime },
20
+ # discount: { name: 'discount', type: Coupon },
21
+ # }
22
+ # end
23
+ # end
24
+ module DTO
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ end
28
+
29
+ # Creates a DTO, populating any properties passed as keyword args, e.g:
30
+ #
31
+ # Hello.new(name: 'World')
32
+ def initialize(**kwargs)
33
+ kwargs.each do |key, value|
34
+ setter = "#{key}="
35
+ unless respond_to?(setter)
36
+ raise ArgumentError, "unknown property '#{key}' for #{self.class}"
37
+ end
38
+
39
+ public_send(setter, value)
40
+ end
41
+ end
42
+
43
+ # The DTO as the Hash it's serialized to, using its wire property names and
44
+ # omitting any properties that aren't populated.
45
+ def to_hash
46
+ to = {}
47
+ self.class.all_properties.each do |attr, meta|
48
+ value = instance_variable_get("@#{attr}")
49
+ next if value.nil?
50
+
51
+ to[meta[:name] || attr.to_s] = Serializer.to_json_value(value)
52
+ end
53
+ to
54
+ end
55
+ alias to_h to_hash
56
+
57
+ # The DTO as its JSON representation.
58
+ def to_json(*args)
59
+ require 'json'
60
+ to_hash.to_json(*args)
61
+ end
62
+
63
+ def ==(other)
64
+ other.is_a?(self.class) && to_hash == other.to_hash
65
+ end
66
+
67
+ def inspect
68
+ "#<#{self.class} #{to_hash.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')}>"
69
+ end
70
+
71
+ module ClassMethods
72
+ # The wire name and Type of each property, overridden by generated DTOs.
73
+ def properties
74
+ {}
75
+ end
76
+
77
+ # This DTO's properties, including the properties it inherits.
78
+ def all_properties
79
+ to = {}
80
+ ancestors.reverse.each do |klass|
81
+ next unless klass.respond_to?(:properties)
82
+
83
+ to.merge!(klass.properties)
84
+ end
85
+ to
86
+ end
87
+
88
+ # Creates a populated DTO from the Hash of a JSON API Response.
89
+ def from_hash(hash)
90
+ return nil if hash.nil?
91
+
92
+ instance = new
93
+ props = all_properties
94
+ hash.each do |key, value|
95
+ attr, meta = find_property(props, key.to_s)
96
+ next if attr.nil?
97
+
98
+ instance.instance_variable_set("@#{attr}", Serializer.from_json_value(meta[:type], value))
99
+ end
100
+ instance
101
+ end
102
+
103
+ # Creates a populated DTO from a JSON string.
104
+ def from_json(json)
105
+ require 'json'
106
+ from_hash(::JSON.parse(json))
107
+ end
108
+
109
+ private
110
+
111
+ # Resolves a JSON property to the DTO property it populates, matching on
112
+ # the wire name first, then the DTO's own snake_case name.
113
+ def find_property(props, json_name)
114
+ props.each do |attr, meta|
115
+ return [attr, meta] if meta[:name] == json_name
116
+ end
117
+
118
+ snake_name = Serializer.snake_case(json_name).to_sym
119
+ meta = props[snake_name]
120
+ return [snake_name, meta] if meta
121
+
122
+ [nil, nil]
123
+ end
124
+ end
125
+
126
+ # Converts values to and from their JSON representation.
127
+ module Serializer
128
+ module_function
129
+
130
+ def to_json_value(value)
131
+ case value
132
+ when nil then nil
133
+ when ::DateTime, ::Time then value.iso8601
134
+ when ::Date then value.iso8601
135
+ when ::Symbol then value.to_s
136
+ when ::Array then value.map { |x| to_json_value(x) }
137
+ when ::Hash then value.each_with_object({}) { |(k, v), to| to[k.to_s] = to_json_value(v) }
138
+ else
139
+ value.respond_to?(:to_hash) && value.class.include?(DTO) ? value.to_hash : value
140
+ end
141
+ end
142
+
143
+ def from_json_value(type, value)
144
+ return nil if value.nil?
145
+
146
+ # Arrays declare their element Type, e.g. type: [Booking]
147
+ if type.is_a?(::Array)
148
+ element_type = type.first
149
+ return value.map { |x| from_json_value(element_type, x) } if value.is_a?(::Array)
150
+
151
+ return value
152
+ end
153
+
154
+ # Hashes declare their key and value Types, e.g. type: { String => Booking }
155
+ if type.is_a?(::Hash)
156
+ value_type = type.values.first
157
+ return value.each_with_object({}) { |(k, v), to| to[k] = from_json_value(value_type, v) } if value.is_a?(::Hash)
158
+
159
+ return value
160
+ end
161
+
162
+ return value if type.nil?
163
+ return parse_date(value) if type == ::DateTime || type == ::Time || type == ::Date
164
+ return value.to_s if type == ::String
165
+ return value.to_i if type == ::Integer && !value.is_a?(::Integer)
166
+ return value.to_f if type == ::Float && !value.is_a?(::Float)
167
+
168
+ if type.is_a?(::Class) && type.include?(DTO) && value.is_a?(::Hash)
169
+ return type.from_hash(value)
170
+ end
171
+
172
+ value
173
+ end
174
+
175
+ def parse_date(value)
176
+ return value unless value.is_a?(::String)
177
+
178
+ # ServiceStack also serializes dates in WCF's /Date(1670000000000)/ format
179
+ if (match = value.match(%r{^/Date\((-?\d+)([+-]\d{4})?\)/$}))
180
+ return ::Time.at(match[1].to_i / 1000.0).to_datetime
181
+ end
182
+
183
+ ::DateTime.parse(value)
184
+ rescue ::ArgumentError, ::TypeError
185
+ value
186
+ end
187
+
188
+ def snake_case(name)
189
+ name.to_s
190
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
191
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
192
+ .tr('-', '_')
193
+ .downcase
194
+ end
195
+
196
+ def camel_case(name)
197
+ parts = name.to_s.split('_')
198
+ ([parts.first] + parts[1..].map(&:capitalize)).join
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'dto'
7
+ require_relative 'types'
8
+ require_relative 'web_service_exception'
9
+
10
+ module ServiceStack
11
+ # HTTP Methods used by ServiceStack APIs.
12
+ module HttpMethods
13
+ GET = 'GET'
14
+ POST = 'POST'
15
+ PUT = 'PUT'
16
+ PATCH = 'PATCH'
17
+ DELETE = 'DELETE'
18
+ OPTIONS = 'OPTIONS'
19
+ HEAD = 'HEAD'
20
+ end
21
+
22
+ # Either the typed Response of a successful API Request or the structured
23
+ # ResponseStatus error of a failed one, returned by `api`.
24
+ class ApiResult
25
+ attr_reader :response, :error
26
+
27
+ def initialize(response: nil, error: nil)
28
+ @response = response
29
+ @error = error
30
+ end
31
+
32
+ def succeeded? = @error.nil?
33
+ def failed? = !@error.nil?
34
+ def error_code = @error&.error_code
35
+ def error_message = @error&.message
36
+ def field_error(field_name) = @error&.field_error(field_name)
37
+ end
38
+
39
+ # Client for consuming ServiceStack APIs with generated typed DTOs.
40
+ #
41
+ # client = ServiceStack::JsonServiceClient.new('https://example.org')
42
+ # res = client.send(Hello.new(name: 'World'))
43
+ # puts res.result
44
+ class JsonServiceClient
45
+ MIME_TYPE_JSON = 'application/json'
46
+
47
+ attr_accessor :base_url, :reply_base_url, :oneway_base_url, :headers,
48
+ :bearer_token, :refresh_token, :refresh_token_uri,
49
+ :user_name, :password, :request_filter, :response_filter,
50
+ :timeout, :cookies
51
+
52
+ class << self
53
+ # Filters applied to every Request and Response of all clients.
54
+ attr_accessor :global_request_filter, :global_response_filter
55
+ end
56
+
57
+ def initialize(base_url)
58
+ raise ArgumentError, 'base_url is required' if base_url.nil? || base_url.to_s.empty?
59
+
60
+ @base_url = base_url.to_s.sub(%r{/+$}, '')
61
+ @headers = { 'Accept' => MIME_TYPE_JSON }
62
+ @cookies = {}
63
+ @timeout = 60
64
+ set_base_path('api')
65
+ end
66
+
67
+ # Changes the base path Request DTOs are sent to, e.g. 'api'.
68
+ # Use an empty base_path for the /json/reply pre-defined routes.
69
+ def set_base_path(base_path = '')
70
+ if base_path.nil? || base_path.to_s.empty?
71
+ @reply_base_url = combine_with(@base_url, 'json/reply')
72
+ @oneway_base_url = combine_with(@base_url, 'json/oneway')
73
+ else
74
+ @reply_base_url = combine_with(@base_url, base_path)
75
+ @oneway_base_url = combine_with(@base_url, base_path)
76
+ end
77
+ self
78
+ end
79
+
80
+ # Sets the JWT or API Key sent in the Bearer Authorization header.
81
+ def set_bearer_token(token)
82
+ @bearer_token = token
83
+ self
84
+ end
85
+
86
+ # Sets the Refresh Token used to fetch a new Bearer Token when a Request
87
+ # returns 401 Unauthorized.
88
+ def set_refresh_token(token)
89
+ @refresh_token = token
90
+ self
91
+ end
92
+
93
+ # Sets the UserName and Password sent in the HTTP Basic Auth header.
94
+ def set_credentials(user_name, password)
95
+ @user_name = user_name
96
+ @password = password
97
+ self
98
+ end
99
+
100
+ # Sets a HTTP Header sent with each Request.
101
+ def set_header(name, value)
102
+ @headers[name] = value
103
+ self
104
+ end
105
+
106
+ # ── Typed API ──
107
+
108
+ # Sends a Request DTO with the HTTP Method it's annotated with, returning
109
+ # its typed Response.
110
+ #
111
+ # Note this overrides Object#send, use __send__ or send_dto for Ruby's
112
+ # dynamic dispatch.
113
+ def send(request, method: nil, body: nil, args: nil)
114
+ method ||= resolve_http_method(request)
115
+ execute_typed(method, create_url_from_dto(method, request), body || request,
116
+ resolve_response_type(request), args: args)
117
+ end
118
+ alias send_dto send
119
+
120
+ def get(request, args: nil) = send(request, method: HttpMethods::GET, args: args)
121
+ def post(request, body: nil, args: nil) = send(request, method: HttpMethods::POST, body: body, args: args)
122
+ def put(request, body: nil, args: nil) = send(request, method: HttpMethods::PUT, body: body, args: args)
123
+ def patch(request, body: nil, args: nil) = send(request, method: HttpMethods::PATCH, body: body, args: args)
124
+ def delete(request, args: nil) = send(request, method: HttpMethods::DELETE, args: args)
125
+
126
+ # Sends a Request DTO that doesn't return a Response Body.
127
+ def send_void(request, args: nil)
128
+ method = resolve_http_method(request)
129
+ execute(method, create_url_from_dto(method, request), request, args: args)
130
+ nil
131
+ end
132
+
133
+ # Sends a Request DTO, returning an ApiResult containing either its typed
134
+ # Response or the structured ResponseStatus error.
135
+ def api(request, method: nil, args: nil)
136
+ ApiResult.new(response: send(request, method: method, args: args))
137
+ rescue WebServiceException => e
138
+ ApiResult.new(error: e.response_status || ResponseStatus.new(
139
+ error_code: e.status_description, message: e.message
140
+ ))
141
+ end
142
+
143
+ # Sends multiple Request DTOs of the same Type in a single Request.
144
+ def send_all(requests)
145
+ return [] if requests.nil? || requests.empty?
146
+
147
+ # Brackets are encoded so the batch URL is a valid URI
148
+ url = combine_with(@reply_base_url, "#{type_name_of(requests.first)}%5B%5D")
149
+ response_type = resolve_response_type(requests.first)
150
+ json = execute(HttpMethods::POST, url, requests)
151
+ parsed = json.to_s.empty? ? [] : JSON.parse(json)
152
+ return parsed unless response_type
153
+
154
+ parsed.map { |x| response_type.from_hash(x) }
155
+ end
156
+
157
+ # Sends a Request DTO to a one-way endpoint, ignoring any Response.
158
+ def publish(request)
159
+ url = combine_with(@oneway_base_url, type_name_of(request))
160
+ execute(HttpMethods::POST, url, request)
161
+ nil
162
+ end
163
+
164
+ # Signs in with UserName and Password credentials, using the Bearer Token
165
+ # and Session Cookies the Server returns for subsequent Requests.
166
+ def authenticate(user_name, password)
167
+ res = send(Authenticate.new(provider: 'credentials', user_name: user_name, password: password))
168
+ @bearer_token = res.bearer_token unless res.bearer_token.to_s.empty?
169
+ @refresh_token = res.refresh_token unless res.refresh_token.to_s.empty?
170
+ res
171
+ end
172
+
173
+ # ── URL API ──
174
+
175
+ # Sends a GET Request to a custom relative path or absolute URL.
176
+ def get_url(path, response_as: nil, args: nil)
177
+ send_url(path, method: HttpMethods::GET, response_as: response_as, args: args)
178
+ end
179
+
180
+ def post_url(path, body: nil, response_as: nil, args: nil)
181
+ send_url(path, method: HttpMethods::POST, body: body, response_as: response_as, args: args)
182
+ end
183
+
184
+ def put_url(path, body: nil, response_as: nil, args: nil)
185
+ send_url(path, method: HttpMethods::PUT, body: body, response_as: response_as, args: args)
186
+ end
187
+
188
+ def patch_url(path, body: nil, response_as: nil, args: nil)
189
+ send_url(path, method: HttpMethods::PATCH, body: body, response_as: response_as, args: args)
190
+ end
191
+
192
+ def delete_url(path, response_as: nil, args: nil)
193
+ send_url(path, method: HttpMethods::DELETE, response_as: response_as, args: args)
194
+ end
195
+
196
+ # Sends a Request to a custom relative path or absolute URL.
197
+ def send_url(path, method: HttpMethods::GET, body: nil, response_as: nil, args: nil)
198
+ execute_typed(method, to_absolute_url(path), body, response_as, args: args)
199
+ end
200
+
201
+ # Sends a Request to a custom URL, returning its raw Response Body.
202
+ def send_url_string(path, method: HttpMethods::GET, body: nil, args: nil)
203
+ execute(method, to_absolute_url(path), body, args: args)
204
+ end
205
+
206
+ # Converts a relative path into an absolute URL of this client.
207
+ def to_absolute_url(path_or_url)
208
+ return path_or_url if path_or_url.to_s.start_with?('http://', 'https://')
209
+
210
+ combine_with(@base_url, path_or_url)
211
+ end
212
+
213
+ # The URL a Request DTO is sent to, appending the populated DTO properties
214
+ # to the QueryString for Requests without a Body.
215
+ def create_url_from_dto(method, request)
216
+ url = combine_with(@reply_base_url, type_name_of(request))
217
+ return url if has_request_body?(method)
218
+
219
+ append_query_string(url, to_hash(request))
220
+ end
221
+
222
+ private
223
+
224
+ def execute_typed(method, url, body, response_type, args: nil)
225
+ json = execute(method, url, body, args: args)
226
+ return nil if response_type.nil?
227
+ return json if response_type == String
228
+
229
+ parsed = json.to_s.strip.empty? ? {} : JSON.parse(json)
230
+ return parsed unless response_type.respond_to?(:from_hash)
231
+
232
+ response_type.from_hash(parsed)
233
+ end
234
+
235
+ def execute(method, url, body, args: nil, retry_on_auth_failure: true)
236
+ url = append_query_string(url, args) if args && !args.empty?
237
+
238
+ uri = URI.parse(url)
239
+ request = new_http_request(method, uri, body)
240
+
241
+ @request_filter&.call(request)
242
+ self.class.global_request_filter&.call(request)
243
+
244
+ response = http_client(uri).request(request)
245
+
246
+ @response_filter&.call(response)
247
+ self.class.global_response_filter&.call(response)
248
+
249
+ capture_cookies(response)
250
+
251
+ status_code = response.code.to_i
252
+ if status_code == 401 && retry_on_auth_failure && refresh_access_token
253
+ return execute(method, url, body, retry_on_auth_failure: false)
254
+ end
255
+
256
+ # Redirects aren't followed, e.g. Services that redirect to a HTML sign in
257
+ # page would otherwise return an empty Response
258
+ raise to_web_service_exception(response) if status_code >= 300
259
+
260
+ response.body
261
+ rescue WebServiceException
262
+ raise
263
+ rescue StandardError => e
264
+ raise WebServiceException.new(e.message, inner_exception: e)
265
+ end
266
+
267
+ def new_http_request(method, uri, body)
268
+ request_class = case method.to_s.upcase
269
+ when HttpMethods::GET then Net::HTTP::Get
270
+ when HttpMethods::POST then Net::HTTP::Post
271
+ when HttpMethods::PUT then Net::HTTP::Put
272
+ when HttpMethods::PATCH then Net::HTTP::Patch
273
+ when HttpMethods::DELETE then Net::HTTP::Delete
274
+ when HttpMethods::OPTIONS then Net::HTTP::Options
275
+ when HttpMethods::HEAD then Net::HTTP::Head
276
+ else Net::HTTP::Post
277
+ end
278
+
279
+ request = request_class.new(uri.request_uri)
280
+ @headers.each { |name, value| request[name] = value }
281
+
282
+ if @bearer_token
283
+ request['Authorization'] = "Bearer #{@bearer_token}"
284
+ elsif @user_name || @password
285
+ request.basic_auth(@user_name.to_s, @password.to_s)
286
+ end
287
+
288
+ request['Cookie'] = @cookies.map { |k, v| "#{k}=#{v}" }.join('; ') unless @cookies.empty?
289
+
290
+ if body && has_request_body?(method)
291
+ request['Content-Type'] ||= MIME_TYPE_JSON
292
+ request.body = body.is_a?(String) ? body : JSON.generate(to_hash(body))
293
+ end
294
+
295
+ request
296
+ end
297
+
298
+ def http_client(uri)
299
+ http = Net::HTTP.new(uri.host, uri.port)
300
+ http.use_ssl = uri.scheme == 'https'
301
+ http.read_timeout = @timeout if @timeout
302
+ http.open_timeout = @timeout if @timeout
303
+ http
304
+ end
305
+
306
+ # Retains the Session Cookies returned by the Server, e.g. ss-id/ss-pid
307
+ def capture_cookies(response)
308
+ cookies = response.get_fields('set-cookie')
309
+ return if cookies.nil?
310
+
311
+ cookies.each do |set_cookie|
312
+ pair = set_cookie.split(';').first.to_s
313
+ name, _, value = pair.partition('=')
314
+ @cookies[name.strip] = value.strip unless name.strip.empty?
315
+ end
316
+ end
317
+
318
+ def refresh_access_token
319
+ return false if @refresh_token.to_s.empty?
320
+
321
+ url = @refresh_token_uri || combine_with(@reply_base_url, 'GetAccessToken')
322
+ json = execute(HttpMethods::POST, to_absolute_url(url),
323
+ GetAccessToken.new(refresh_token: @refresh_token),
324
+ retry_on_auth_failure: false)
325
+ res = GetAccessTokenResponse.from_hash(JSON.parse(json.to_s.empty? ? '{}' : json))
326
+ return false if res.access_token.to_s.empty?
327
+
328
+ @bearer_token = res.access_token
329
+ true
330
+ rescue StandardError
331
+ false
332
+ end
333
+
334
+ def to_web_service_exception(response)
335
+ status_code = response.code.to_i
336
+ response_status = nil
337
+
338
+ body = response.body.to_s
339
+ unless body.empty?
340
+ begin
341
+ hash = JSON.parse(body)
342
+ if hash.is_a?(Hash)
343
+ status_hash = hash['responseStatus'] || hash['ResponseStatus']
344
+ status_hash = hash if status_hash.nil? && (hash['errorCode'] || hash['message'])
345
+ response_status = ResponseStatus.from_hash(status_hash) if status_hash
346
+ end
347
+ rescue JSON::ParserError
348
+ # Services can return non JSON errors, e.g. a HTML error page
349
+ end
350
+ end
351
+
352
+ if response_status.nil? && status_code >= 300 && status_code < 400
353
+ location = response['location']
354
+ response_status = ResponseStatus.new(
355
+ error_code: 'Redirect',
356
+ message: "Request was redirected#{location ? " to #{location}" : ''}"
357
+ )
358
+ end
359
+
360
+ response_status ||= ResponseStatus.new(error_code: response.message, message: response.message)
361
+
362
+ WebServiceException.new(
363
+ response_status.message || response.message,
364
+ status_code: status_code,
365
+ status_description: response.message,
366
+ response_status: response_status,
367
+ response_body: body
368
+ )
369
+ end
370
+
371
+ def resolve_response_type(request)
372
+ return nil unless request.respond_to?(:response_type)
373
+
374
+ request.response_type
375
+ end
376
+
377
+ def resolve_http_method(request)
378
+ return request.get_method if request.respond_to?(:get_method) && !request.get_method.to_s.empty?
379
+
380
+ name = type_name_of(request)
381
+ case name
382
+ when /\A(Get|Query|Find|Search)/ then HttpMethods::GET
383
+ when /\A(Create)/ then HttpMethods::POST
384
+ when /\A(Update|Replace)/ then HttpMethods::PUT
385
+ when /\A(Patch)/ then HttpMethods::PATCH
386
+ when /\A(Delete|Remove)/ then HttpMethods::DELETE
387
+ else HttpMethods::POST
388
+ end
389
+ end
390
+
391
+ def type_name_of(request)
392
+ return request.get_type_name if request.respond_to?(:get_type_name)
393
+
394
+ request.class.name.to_s.split('::').last
395
+ end
396
+
397
+ def to_hash(dto)
398
+ return dto if dto.is_a?(String)
399
+ return dto.map { |x| to_hash(x) } if dto.is_a?(Array)
400
+ return dto.to_hash if dto.respond_to?(:to_hash)
401
+
402
+ dto
403
+ end
404
+
405
+ def has_request_body?(method)
406
+ !%w[GET DELETE HEAD OPTIONS].include?(method.to_s.upcase)
407
+ end
408
+
409
+ def combine_with(base_url, path)
410
+ base = base_url.to_s.sub(%r{/+$}, '')
411
+ rel = path.to_s.sub(%r{\A/+}, '').sub(%r{/+$}, '')
412
+ return base if rel.empty?
413
+ return rel if base.empty?
414
+
415
+ "#{base}/#{rel}"
416
+ end
417
+
418
+ def append_query_string(url, args)
419
+ return url if args.nil? || args.empty?
420
+
421
+ params = args.filter_map do |key, value|
422
+ next if value.nil?
423
+
424
+ "#{URI.encode_www_form_component(key.to_s)}=#{URI.encode_www_form_component(qs_value(value))}"
425
+ end
426
+ return url if params.empty?
427
+
428
+ "#{url}#{url.include?('?') ? '&' : '?'}#{params.join('&')}"
429
+ end
430
+
431
+ def qs_value(value)
432
+ case value
433
+ when nil then ''
434
+ when true, false then value.to_s
435
+ when Array then "[#{value.map { |x| qs_value(x) }.join(',')}]"
436
+ when Hash then "{#{value.map { |k, v| "#{k}:#{qs_value(v)}" }.join(',')}}"
437
+ when DateTime, Time, Date then value.iso8601
438
+ else value.to_s
439
+ end
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dto'
4
+
5
+ module ServiceStack
6
+ # A field validation error within a ResponseStatus.
7
+ class ResponseError
8
+ include DTO
9
+ attr_accessor :error_code, :field_name, :message, :meta
10
+
11
+ def self.properties
12
+ {
13
+ error_code: { name: 'errorCode' },
14
+ field_name: { name: 'fieldName' },
15
+ message: { name: 'message' },
16
+ meta: { name: 'meta' },
17
+ }
18
+ end
19
+ end
20
+
21
+ # ServiceStack's structured error, returned in the `responseStatus` property
22
+ # of failed API Responses.
23
+ class ResponseStatus
24
+ include DTO
25
+ attr_accessor :error_code, :message, :stack_trace, :errors, :meta
26
+
27
+ def self.properties
28
+ {
29
+ error_code: { name: 'errorCode' },
30
+ message: { name: 'message' },
31
+ stack_trace: { name: 'stackTrace' },
32
+ errors: { name: 'errors', type: [ResponseError] },
33
+ meta: { name: 'meta' },
34
+ }
35
+ end
36
+
37
+ # The validation error message for the field, matched case-insensitively.
38
+ def field_error(field_name)
39
+ error = get_field_error(field_name)
40
+ error&.message
41
+ end
42
+
43
+ # The ResponseError for the field, matched case-insensitively.
44
+ def get_field_error(field_name)
45
+ (errors || []).find { |x| x.field_name.to_s.casecmp?(field_name.to_s) }
46
+ end
47
+ end
48
+
49
+ # Returned by APIs with no Response Body.
50
+ class EmptyResponse
51
+ include DTO
52
+ attr_accessor :response_status
53
+
54
+ def self.properties
55
+ { response_status: { name: 'responseStatus', type: ResponseStatus } }
56
+ end
57
+ end
58
+
59
+ # Returned by APIs that return the Id of the created or updated entity.
60
+ class IdResponse
61
+ include DTO
62
+ attr_accessor :id, :response_status
63
+
64
+ def self.properties
65
+ {
66
+ id: { name: 'id' },
67
+ response_status: { name: 'responseStatus', type: ResponseStatus },
68
+ }
69
+ end
70
+ end
71
+
72
+ # Returned by APIs that return a single string result.
73
+ class StringResponse
74
+ include DTO
75
+ attr_accessor :result, :meta, :response_status
76
+
77
+ def self.properties
78
+ {
79
+ result: { name: 'result' },
80
+ meta: { name: 'meta' },
81
+ response_status: { name: 'responseStatus', type: ResponseStatus },
82
+ }
83
+ end
84
+ end
85
+
86
+ # Returned by APIs that return a list of string results.
87
+ class StringsResponse
88
+ include DTO
89
+ attr_accessor :results, :meta, :response_status
90
+
91
+ def self.properties
92
+ {
93
+ results: { name: 'results' },
94
+ meta: { name: 'meta' },
95
+ response_status: { name: 'responseStatus', type: ResponseStatus },
96
+ }
97
+ end
98
+ end
99
+
100
+ # The audit fields of AutoQuery CRUD data models.
101
+ class AuditBase
102
+ include DTO
103
+ attr_accessor :created_date, :created_by, :modified_date, :modified_by, :deleted_date, :deleted_by
104
+
105
+ def self.properties
106
+ {
107
+ created_date: { name: 'createdDate', type: DateTime },
108
+ created_by: { name: 'createdBy' },
109
+ modified_date: { name: 'modifiedDate', type: DateTime },
110
+ modified_by: { name: 'modifiedBy' },
111
+ deleted_date: { name: 'deletedDate', type: DateTime },
112
+ deleted_by: { name: 'deletedBy' },
113
+ }
114
+ end
115
+ end
116
+
117
+ # The query params supported by all AutoQuery Requests.
118
+ class QueryBase
119
+ include DTO
120
+ attr_accessor :skip, :take, :order_by, :order_by_desc, :include, :fields, :meta
121
+
122
+ def self.properties
123
+ {
124
+ skip: { name: 'skip' },
125
+ take: { name: 'take' },
126
+ order_by: { name: 'orderBy' },
127
+ order_by_desc: { name: 'orderByDesc' },
128
+ include: { name: 'include' },
129
+ fields: { name: 'fields' },
130
+ meta: { name: 'meta' },
131
+ }
132
+ end
133
+ end
134
+
135
+ # The base of AutoQuery RDBMS Requests.
136
+ class QueryDb < QueryBase; end
137
+
138
+ # The base of AutoQuery Data Requests.
139
+ class QueryData < QueryBase; end
140
+
141
+ # The typed Response of AutoQuery Requests.
142
+ #
143
+ # Use `QueryResponse.of(Booking)` for a Response that converts its `results`
144
+ # into the specified Type, which is what generated AutoQuery DTOs return.
145
+ class QueryResponse
146
+ include DTO
147
+ attr_accessor :offset, :total, :results, :meta, :response_status
148
+
149
+ class << self
150
+ attr_accessor :results_type
151
+
152
+ # A QueryResponse that converts its results into the specified Type.
153
+ def of(type)
154
+ @of_types ||= {}
155
+ @of_types[type] ||= Class.new(self) do
156
+ self.results_type = type
157
+ end
158
+ end
159
+
160
+ def properties
161
+ {
162
+ offset: { name: 'offset' },
163
+ total: { name: 'total' },
164
+ results: { name: 'results', type: [results_type].compact },
165
+ meta: { name: 'meta' },
166
+ response_status: { name: 'responseStatus', type: ResponseStatus },
167
+ }
168
+ end
169
+ end
170
+ end
171
+
172
+ # Authenticate with a ServiceStack Service.
173
+ class Authenticate
174
+ include DTO
175
+ attr_accessor :provider, :user_name, :password, :remember_me, :access_token,
176
+ :access_token_secret, :return_url, :error_view, :meta
177
+
178
+ def self.properties
179
+ {
180
+ provider: { name: 'provider' },
181
+ user_name: { name: 'userName' },
182
+ password: { name: 'password' },
183
+ remember_me: { name: 'rememberMe' },
184
+ access_token: { name: 'accessToken' },
185
+ access_token_secret: { name: 'accessTokenSecret' },
186
+ return_url: { name: 'returnUrl' },
187
+ error_view: { name: 'errorView' },
188
+ meta: { name: 'meta' },
189
+ }
190
+ end
191
+
192
+ def response_type = AuthenticateResponse
193
+ def get_type_name = 'Authenticate'
194
+ def get_method = 'POST'
195
+ end
196
+
197
+ # The Response of a successful Authenticate Request.
198
+ class AuthenticateResponse
199
+ include DTO
200
+ attr_accessor :user_id, :session_id, :user_name, :display_name, :referrer_url,
201
+ :bearer_token, :refresh_token, :refresh_token_expiry, :profile_url,
202
+ :roles, :permissions, :auth_provider, :response_status, :meta
203
+
204
+ def self.properties
205
+ {
206
+ user_id: { name: 'userId' },
207
+ session_id: { name: 'sessionId' },
208
+ user_name: { name: 'userName' },
209
+ display_name: { name: 'displayName' },
210
+ referrer_url: { name: 'referrerUrl' },
211
+ bearer_token: { name: 'bearerToken' },
212
+ refresh_token: { name: 'refreshToken' },
213
+ refresh_token_expiry: { name: 'refreshTokenExpiry', type: DateTime },
214
+ profile_url: { name: 'profileUrl' },
215
+ roles: { name: 'roles' },
216
+ permissions: { name: 'permissions' },
217
+ auth_provider: { name: 'authProvider' },
218
+ response_status: { name: 'responseStatus', type: ResponseStatus },
219
+ meta: { name: 'meta' },
220
+ }
221
+ end
222
+ end
223
+
224
+ # Register a new User.
225
+ class Register
226
+ include DTO
227
+ attr_accessor :user_name, :first_name, :last_name, :display_name, :email,
228
+ :password, :confirm_password, :auto_login, :error_view, :meta
229
+
230
+ def self.properties
231
+ {
232
+ user_name: { name: 'userName' },
233
+ first_name: { name: 'firstName' },
234
+ last_name: { name: 'lastName' },
235
+ display_name: { name: 'displayName' },
236
+ email: { name: 'email' },
237
+ password: { name: 'password' },
238
+ confirm_password: { name: 'confirmPassword' },
239
+ auto_login: { name: 'autoLogin' },
240
+ error_view: { name: 'errorView' },
241
+ meta: { name: 'meta' },
242
+ }
243
+ end
244
+
245
+ def response_type = RegisterResponse
246
+ def get_type_name = 'Register'
247
+ def get_method = 'POST'
248
+ end
249
+
250
+ # The Response of a successful Register Request.
251
+ class RegisterResponse
252
+ include DTO
253
+ attr_accessor :user_id, :session_id, :user_name, :referrer_url, :bearer_token,
254
+ :refresh_token, :refresh_token_expiry, :roles, :permissions,
255
+ :redirect_url, :response_status, :meta
256
+
257
+ def self.properties
258
+ {
259
+ user_id: { name: 'userId' },
260
+ session_id: { name: 'sessionId' },
261
+ user_name: { name: 'userName' },
262
+ referrer_url: { name: 'referrerUrl' },
263
+ bearer_token: { name: 'bearerToken' },
264
+ refresh_token: { name: 'refreshToken' },
265
+ refresh_token_expiry: { name: 'refreshTokenExpiry', type: DateTime },
266
+ roles: { name: 'roles' },
267
+ permissions: { name: 'permissions' },
268
+ redirect_url: { name: 'redirectUrl' },
269
+ response_status: { name: 'responseStatus', type: ResponseStatus },
270
+ meta: { name: 'meta' },
271
+ }
272
+ end
273
+ end
274
+
275
+ # Exchange a Refresh Token for a new JWT Bearer Token.
276
+ class GetAccessToken
277
+ include DTO
278
+ attr_accessor :refresh_token, :meta
279
+
280
+ def self.properties
281
+ {
282
+ refresh_token: { name: 'refreshToken' },
283
+ meta: { name: 'meta' },
284
+ }
285
+ end
286
+
287
+ def response_type = GetAccessTokenResponse
288
+ def get_type_name = 'GetAccessToken'
289
+ def get_method = 'POST'
290
+ end
291
+
292
+ # The Response of GetAccessToken.
293
+ class GetAccessTokenResponse
294
+ include DTO
295
+ attr_accessor :access_token, :response_status, :meta
296
+
297
+ def self.properties
298
+ {
299
+ access_token: { name: 'accessToken' },
300
+ response_status: { name: 'responseStatus', type: ResponseStatus },
301
+ meta: { name: 'meta' },
302
+ }
303
+ end
304
+ end
305
+
306
+ # Convert an authenticated Session into a JWT Bearer Token.
307
+ class ConvertSessionToToken
308
+ include DTO
309
+ attr_accessor :preserve_session, :meta
310
+
311
+ def self.properties
312
+ {
313
+ preserve_session: { name: 'preserveSession' },
314
+ meta: { name: 'meta' },
315
+ }
316
+ end
317
+
318
+ def response_type = ConvertSessionToTokenResponse
319
+ def get_type_name = 'ConvertSessionToToken'
320
+ def get_method = 'POST'
321
+ end
322
+
323
+ # The Response of ConvertSessionToToken.
324
+ class ConvertSessionToTokenResponse
325
+ include DTO
326
+ attr_accessor :access_token, :refresh_token, :response_status, :meta
327
+
328
+ def self.properties
329
+ {
330
+ access_token: { name: 'accessToken' },
331
+ refresh_token: { name: 'refreshToken' },
332
+ response_status: { name: 'responseStatus', type: ResponseStatus },
333
+ meta: { name: 'meta' },
334
+ }
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceStack
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceStack
4
+ # Raised for failed API Requests, containing the HTTP Status Code and the
5
+ # structured ResponseStatus error when the Server returned one.
6
+ #
7
+ # begin
8
+ # client.send(CreateBooking.new)
9
+ # rescue ServiceStack::WebServiceException => e
10
+ # puts e.status_code # 400
11
+ # puts e.error_code # "NotEmpty"
12
+ # puts e.field_error('Name') # "'Name' must not be empty."
13
+ # end
14
+ class WebServiceException < StandardError
15
+ attr_reader :status_code, :status_description, :response_status, :response_body, :inner_exception
16
+
17
+ def initialize(message = nil, status_code: 0, status_description: nil, response_status: nil,
18
+ response_body: nil, inner_exception: nil)
19
+ super(message || status_description || "HTTP Error #{status_code}")
20
+ @status_code = status_code
21
+ @status_description = status_description
22
+ @response_status = response_status
23
+ @response_body = response_body
24
+ @inner_exception = inner_exception
25
+ end
26
+
27
+ # The ErrorCode of the error, e.g. "NotFound".
28
+ def error_code = @response_status&.error_code
29
+
30
+ # The error message.
31
+ def error_message = @response_status&.message || message
32
+
33
+ # Any field validation errors.
34
+ def field_errors = @response_status&.errors || []
35
+
36
+ # The validation error message for the field, if it has one.
37
+ def field_error(field_name) = @response_status&.field_error(field_name)
38
+
39
+ def unauthorized? = @status_code == 401
40
+ def forbidden? = @status_code == 403
41
+ def not_found? = @status_code == 404
42
+ def validation_error? = !field_errors.empty?
43
+ end
44
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Typed Ruby Client Library for consuming ServiceStack APIs.
4
+ #
5
+ # Generate typed DTOs for a remote ServiceStack API with get-dtos:
6
+ #
7
+ # npx get-dtos ruby https://blazor-vue.web-templates.io
8
+ #
9
+ # Then send them with the client, which resolves each API's route, HTTP Method
10
+ # and Response Type from its Request DTO:
11
+ #
12
+ # require 'servicestack'
13
+ # require_relative 'dtos'
14
+ #
15
+ # client = ServiceStack::JsonServiceClient.new('https://blazor-vue.web-templates.io')
16
+ # res = client.send(Hello.new(name: 'World'))
17
+ # puts res.result
18
+ require_relative 'servicestack/version'
19
+ require_relative 'servicestack/dto'
20
+ require_relative 'servicestack/types'
21
+ require_relative 'servicestack/web_service_exception'
22
+ require_relative 'servicestack/json_service_client'
23
+
24
+ module ServiceStack
25
+ class Error < StandardError; end
26
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: servicestack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ServiceStack
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Send generated typed Request DTOs to any ServiceStack API, with structured
13
+ ResponseStatus errors, AutoQuery, batched Requests and Bearer Token, API Key, Basic
14
+ Auth and Session Cookie authentication.
15
+ email:
16
+ - team@servicestack.net
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/servicestack.rb
25
+ - lib/servicestack/dto.rb
26
+ - lib/servicestack/json_service_client.rb
27
+ - lib/servicestack/types.rb
28
+ - lib/servicestack/version.rb
29
+ - lib/servicestack/web_service_exception.rb
30
+ homepage: https://github.com/ServiceStack/servicestack-ruby
31
+ licenses:
32
+ - BSD-3-Clause
33
+ metadata:
34
+ homepage_uri: https://github.com/ServiceStack/servicestack-ruby
35
+ source_code_uri: https://github.com/ServiceStack/servicestack-ruby
36
+ changelog_uri: https://github.com/ServiceStack/servicestack-ruby/blob/main/CHANGELOG.md
37
+ rubygems_mfa_required: 'false'
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 3.0.0
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.7.2
53
+ specification_version: 4
54
+ summary: Typed Ruby Client Library for consuming ServiceStack APIs
55
+ test_files: []