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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +29 -0
- data/README.md +206 -0
- data/lib/servicestack/dto.rb +202 -0
- data/lib/servicestack/json_service_client.rb +442 -0
- data/lib/servicestack/types.rb +337 -0
- data/lib/servicestack/version.rb +5 -0
- data/lib/servicestack/web_service_exception.rb +44 -0
- data/lib/servicestack.rb +26 -0
- metadata +55 -0
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,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
|
data/lib/servicestack.rb
ADDED
|
@@ -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: []
|