client-api-builder 0.2.3 → 0.2.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/client-api-builder.gemspec +3 -1
- data/examples/basic_auth_example_client.rb +20 -0
- data/examples/imdb_datasets_client.rb +31 -0
- data/lib/client-api-builder.rb +3 -1
- data/lib/client_api_builder/nested_router.rb +100 -0
- data/lib/client_api_builder/net_http_request.rb +2 -2
- data/lib/client_api_builder/router.rb +86 -12
- data/lib/client_api_builder/section.rb +34 -0
- data/script/console +3 -0
- metadata +20 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 920aa0f202c1e60cf6e3dc8bf0e44d289f3cc37ec6052b2bca3535493065bb7e
|
4
|
+
data.tar.gz: c8f534c244d2c572f36f9b42a64610bde62a7bbc8b8d54ef2ae7049982f6a1c0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 971aea4195329e45bf445ec744bcffaf3fc9a4d1a4eed5b88ec4be973f30daa568e7bca15180bc008b7f36967144fb130df67a11a4b060a96bae93ad1fd8ce32
|
7
|
+
data.tar.gz: ded78f47f7b31dfb671fb8bcd8ee383e0e965a16c94090cabba69a2bc1eedd678f99acc9baa2e89c23a391df6d7dd3e7b4ed3634d1722bda18f13cfd505696c2
|
data/Gemfile.lock
CHANGED
data/client-api-builder.gemspec
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
Gem::Specification.new do |s|
|
4
4
|
s.name = 'client-api-builder'
|
5
|
-
s.version = '0.2.
|
5
|
+
s.version = '0.2.7'
|
6
6
|
s.licenses = ['MIT']
|
7
7
|
s.summary = 'Develop Client API libraries faster'
|
8
8
|
s.description = 'Utility for constructing API clients'
|
@@ -10,4 +10,6 @@ Gem::Specification.new do |s|
|
|
10
10
|
s.email = 'dougyouch@gmail.com'
|
11
11
|
s.homepage = 'https://github.com/dougyouch/client-api-builder'
|
12
12
|
s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
13
|
+
|
14
|
+
s.add_runtime_dependency 'inheritance-helper', '>= 0.2.5'
|
13
15
|
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'base64'
|
2
|
+
require 'securerandom'
|
2
3
|
|
3
4
|
class BasicAuthExampleClient < Struct.new(
|
4
5
|
:username,
|
@@ -6,6 +7,7 @@ class BasicAuthExampleClient < Struct.new(
|
|
6
7
|
)
|
7
8
|
|
8
9
|
include ClientApiBuilder::Router
|
10
|
+
include ClientApiBuilder::Section
|
9
11
|
|
10
12
|
base_url 'https://www.example.com'
|
11
13
|
|
@@ -15,9 +17,27 @@ class BasicAuthExampleClient < Struct.new(
|
|
15
17
|
route :get_apps, '/apps'
|
16
18
|
route :get_app, '/apps/:app_id'
|
17
19
|
|
20
|
+
section :users do
|
21
|
+
header 'Authorization', :bearer_authorization
|
22
|
+
|
23
|
+
route :create_user, '/users?z={cache_buster}'
|
24
|
+
end
|
25
|
+
|
26
|
+
def cache_buster
|
27
|
+
(Time.now.to_f * 1000).to_i
|
28
|
+
end
|
29
|
+
|
18
30
|
private
|
19
31
|
|
32
|
+
def auth_token
|
33
|
+
@auth_token ||= SecureRandom.uuid
|
34
|
+
end
|
35
|
+
|
20
36
|
def basic_authorization
|
21
37
|
'basic ' + Base64.strict_encode64(username + ':' + password)
|
22
38
|
end
|
39
|
+
|
40
|
+
def bearer_authorization
|
41
|
+
'bearer ' + auth_token
|
42
|
+
end
|
23
43
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class IMDBDatesetsClient
|
2
|
+
include ClientApiBuilder::Router
|
3
|
+
|
4
|
+
base_url 'https://datasets.imdbws.com'
|
5
|
+
|
6
|
+
route :get_name_basics, '/name.basics.tsv.gz', stream: :file
|
7
|
+
route :get_title_akas, '/title.akas.tsv.gz', stream: :io
|
8
|
+
route :get_title_basics, '/title.basics.tsv.gz', stream: :block
|
9
|
+
|
10
|
+
def self.stream_to_file
|
11
|
+
new.get_name_basics(file: 'name.basics.tsv.gz')
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.stream_to_io
|
15
|
+
File.open('title.akas.tsv.gz', 'wb') do |io|
|
16
|
+
new.get_title_akas(io: io)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.stream_with_block
|
21
|
+
File.open('title.basics.tsv.gz', 'wb') do |io|
|
22
|
+
total_read = 0.0
|
23
|
+
new.get_title_basics do |response, chunk|
|
24
|
+
total_read += chunk.bytesize
|
25
|
+
percentage_complete = ((total_read / response.content_length) * 100).to_i
|
26
|
+
puts "downloading title.basics.tsv.gz completed: #{percentage_complete}%"
|
27
|
+
io.write chunk
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/client-api-builder.rb
CHANGED
@@ -11,8 +11,10 @@ module ClientApiBuilder
|
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
|
-
autoload :
|
14
|
+
autoload :NestedRouter, 'client_api_builder/nested_router'
|
15
15
|
autoload :QueryParams, 'client_api_builder/query_params'
|
16
|
+
autoload :Router, 'client_api_builder/router'
|
17
|
+
autoload :Section, 'client_api_builder/section'
|
16
18
|
|
17
19
|
module NetHTTP
|
18
20
|
autoload :Request, 'client_api_builder/net_http_request'
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Purpose: to nest routers, which are sub sections of APIs
|
4
|
+
# for example if you had an entire section of your API dedicatd to user management.
|
5
|
+
# you may want to nest all calls to those routes under the user section
|
6
|
+
# ex: client.users.get_user(id: 1) # where users is a nested router
|
7
|
+
module ClientApiBuilder
|
8
|
+
class NestedRouter
|
9
|
+
include ::ClientApiBuilder::Router
|
10
|
+
|
11
|
+
attr_reader :root_router
|
12
|
+
|
13
|
+
def initialize(root_router)
|
14
|
+
@root_router = root_router
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.get_instance_method(var)
|
18
|
+
"\#{root_router.#{var}\}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def request(**options, &block)
|
22
|
+
root_router.request(**options, &block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def stream(**options, &block)
|
26
|
+
root_router.stream(**options, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
def stream_to_io(**options, &block)
|
30
|
+
root_router.stream_to_io(**options, &block)
|
31
|
+
end
|
32
|
+
|
33
|
+
def stream_to_file(**options, &block)
|
34
|
+
root_router.stream_to_file(**options, &block)
|
35
|
+
end
|
36
|
+
|
37
|
+
def base_url(options)
|
38
|
+
self.class.base_url || root_router.base_url(options)
|
39
|
+
end
|
40
|
+
|
41
|
+
def build_headers(options)
|
42
|
+
headers = root_router.build_headers(options)
|
43
|
+
|
44
|
+
add_header_proc = proc do |name, value|
|
45
|
+
headers[name] =
|
46
|
+
if value.is_a?(Proc)
|
47
|
+
root_router.instance_eval(&value)
|
48
|
+
elsif value.is_a?(Symbol)
|
49
|
+
root_router.send(value)
|
50
|
+
else
|
51
|
+
value
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
self.class.headers.each(&add_header_proc)
|
56
|
+
|
57
|
+
headers
|
58
|
+
end
|
59
|
+
|
60
|
+
def build_connection_options(options)
|
61
|
+
root_router.build_connection_options(options)
|
62
|
+
end
|
63
|
+
|
64
|
+
def build_query(query, options)
|
65
|
+
return nil if query.nil? && root_router.class.query_params.empty? && self.class.query_params.empty?
|
66
|
+
|
67
|
+
query_params = {}
|
68
|
+
|
69
|
+
add_query_param_proc = proc do |name, value|
|
70
|
+
query_params[name] =
|
71
|
+
if value.is_a?(Proc)
|
72
|
+
root_router.instance_eval(&value)
|
73
|
+
elsif value.is_a?(Symbol)
|
74
|
+
root_router.send(value)
|
75
|
+
else
|
76
|
+
value
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
root_router.class.query_params.each(&add_query_param_proc)
|
81
|
+
self.class.query_params.each(&add_query_param_proc)
|
82
|
+
query && query.each(&add_query_param_proc)
|
83
|
+
options[:query] && options[:query].each(&add_query_param_proc)
|
84
|
+
|
85
|
+
self.class.build_query(query_params)
|
86
|
+
end
|
87
|
+
|
88
|
+
def build_body(body, options)
|
89
|
+
root_router.build_body(body, options)
|
90
|
+
end
|
91
|
+
|
92
|
+
def expected_response_code!(response, expected_response_codes, options)
|
93
|
+
root_router.expected_response_code!(response, expected_response_codes, options)
|
94
|
+
end
|
95
|
+
|
96
|
+
def handle_response(response, options, &block)
|
97
|
+
root_router.handle_response(response, options, &block)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -37,13 +37,13 @@ module ClientApiBuilder
|
|
37
37
|
def stream(method:, uri:, body:, headers:, connection_options:)
|
38
38
|
request(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options) do |response|
|
39
39
|
response.read_body do |chunk|
|
40
|
-
yield chunk
|
40
|
+
yield response, chunk
|
41
41
|
end
|
42
42
|
end
|
43
43
|
end
|
44
44
|
|
45
45
|
def stream_to_io(method:, uri:, body:, headers:, connection_options:, io:)
|
46
|
-
stream(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options) do |chunk|
|
46
|
+
stream(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options) do |_, chunk|
|
47
47
|
io.write chunk
|
48
48
|
end
|
49
49
|
end
|
@@ -6,6 +6,7 @@ module ClientApiBuilder
|
|
6
6
|
def self.included(base)
|
7
7
|
base.extend InheritanceHelper::Methods
|
8
8
|
base.extend ClassMethods
|
9
|
+
base.include ::ClientApiBuilder::Section
|
9
10
|
base.include ::ClientApiBuilder::NetHTTP::Request
|
10
11
|
base.attr_reader :response, :request_options
|
11
12
|
end
|
@@ -20,19 +21,39 @@ module ClientApiBuilder
|
|
20
21
|
def default_options
|
21
22
|
{
|
22
23
|
base_url: nil,
|
23
|
-
|
24
|
+
body_builder: :to_json,
|
24
25
|
connection_options: {},
|
26
|
+
headers: {},
|
27
|
+
query_builder: Hash.method_defined?(:to_query) ? :to_query : :query_params,
|
25
28
|
query_params: {},
|
26
|
-
|
29
|
+
response_procs: {}
|
27
30
|
}.freeze
|
28
31
|
end
|
29
32
|
|
33
|
+
|
34
|
+
def add_response_procs(method_name, proc)
|
35
|
+
response_procs = default_options[:response_procs].dup
|
36
|
+
response_procs[method_name] = proc
|
37
|
+
add_value_to_class_method(:default_options, response_procs: response_procs)
|
38
|
+
end
|
39
|
+
|
40
|
+
def response_proc(method_name)
|
41
|
+
proc = default_options[:response_procs][method_name]
|
42
|
+
proc
|
43
|
+
end
|
44
|
+
|
30
45
|
def base_url(url = nil)
|
31
46
|
return default_options[:base_url] unless url
|
32
47
|
|
33
48
|
add_value_to_class_method(:default_options, base_url: url)
|
34
49
|
end
|
35
50
|
|
51
|
+
def body_builder(builder = nil)
|
52
|
+
return default_options[:body_builder] unless builder
|
53
|
+
|
54
|
+
add_value_to_class_method(:default_options, body_builder: builder)
|
55
|
+
end
|
56
|
+
|
36
57
|
def query_builder(builder = nil)
|
37
58
|
return default_options[:query_builder] unless builder
|
38
59
|
|
@@ -69,6 +90,21 @@ module ClientApiBuilder
|
|
69
90
|
default_options[:query_params]
|
70
91
|
end
|
71
92
|
|
93
|
+
def build_body(router, body, options)
|
94
|
+
builder = options[:body_builder] || body_builder
|
95
|
+
|
96
|
+
case builder
|
97
|
+
when :to_json
|
98
|
+
body.to_json
|
99
|
+
when :to_query
|
100
|
+
body.to_query
|
101
|
+
when :query_params
|
102
|
+
ClientApiBuilder::QueryParams.to_query(body)
|
103
|
+
else
|
104
|
+
router.instance_exec(body, &builder)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
72
108
|
def build_query(query)
|
73
109
|
case query_builder
|
74
110
|
when :to_query
|
@@ -111,6 +147,8 @@ module ClientApiBuilder
|
|
111
147
|
arguments += get_hash_arguments(v)
|
112
148
|
when Array
|
113
149
|
arguments += get_array_arguments(v)
|
150
|
+
when String
|
151
|
+
hsh[k] = "__||#{$1}||__" if v =~ /\{([a-z0-9_]+)\}/i
|
114
152
|
end
|
115
153
|
end
|
116
154
|
arguments
|
@@ -127,6 +165,8 @@ module ClientApiBuilder
|
|
127
165
|
arguments += get_hash_arguments(v)
|
128
166
|
when Array
|
129
167
|
arguments += get_array_arguments(v)
|
168
|
+
when String
|
169
|
+
list[idx] = "__||#{$1}||__" if v =~ /\{([a-z0-9_]+)\}/i
|
130
170
|
end
|
131
171
|
end
|
132
172
|
arguments
|
@@ -143,9 +183,31 @@ module ClientApiBuilder
|
|
143
183
|
end
|
144
184
|
end
|
145
185
|
|
186
|
+
def get_instance_method(var)
|
187
|
+
"#\{#{var}\}"
|
188
|
+
end
|
189
|
+
|
190
|
+
@@namespaces = []
|
191
|
+
def namespaces
|
192
|
+
@@namespaces
|
193
|
+
end
|
194
|
+
|
195
|
+
def namespace(name)
|
196
|
+
namespaces << name
|
197
|
+
yield
|
198
|
+
namespaces.pop
|
199
|
+
end
|
200
|
+
|
146
201
|
def generate_route_code(method_name, path, options = {})
|
147
202
|
http_method = options[:method] || http_method(method_name)
|
148
203
|
|
204
|
+
path = namespaces.join + path
|
205
|
+
|
206
|
+
# instance method
|
207
|
+
path.gsub!(/\{([a-z0-9_]+)\}/i) do |_|
|
208
|
+
get_instance_method($1)
|
209
|
+
end
|
210
|
+
|
149
211
|
path_arguments = []
|
150
212
|
path.gsub!(/:([a-z0-9_]+)/i) do |_|
|
151
213
|
path_arguments << $1
|
@@ -207,6 +269,7 @@ module ClientApiBuilder
|
|
207
269
|
method_args += ['**__options__', '&block']
|
208
270
|
|
209
271
|
code = "def #{method_name}(" + method_args.join(', ') + ")\n"
|
272
|
+
code += " block ||= self.class.response_proc(#{method_name.inspect})\n"
|
210
273
|
code += " __path__ = \"#{path}\"\n"
|
211
274
|
code += " __query__ = #{query}\n"
|
212
275
|
code += " __body__ = #{body}\n"
|
@@ -220,9 +283,10 @@ module ClientApiBuilder
|
|
220
283
|
|
221
284
|
case options[:stream]
|
222
285
|
when true,
|
223
|
-
:file
|
224
|
-
:io
|
286
|
+
:file
|
225
287
|
code += " @response = stream_to_file(**@request_options)\n"
|
288
|
+
when :io
|
289
|
+
code += " @response = stream_to_io(**@request_options)\n"
|
226
290
|
when :block
|
227
291
|
code += " @response = stream(**@request_options, &block)\n"
|
228
292
|
else
|
@@ -243,13 +307,15 @@ module ClientApiBuilder
|
|
243
307
|
code
|
244
308
|
end
|
245
309
|
|
246
|
-
def route(method_name, path, options = {})
|
310
|
+
def route(method_name, path, options = {}, &block)
|
311
|
+
add_response_procs(method_name, block) if block
|
312
|
+
|
247
313
|
self.class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
|
248
314
|
end
|
249
315
|
end
|
250
316
|
|
251
317
|
def base_url(options)
|
252
|
-
self.class.base_url
|
318
|
+
options[:base_url] || self.class.base_url
|
253
319
|
end
|
254
320
|
|
255
321
|
def build_headers(options)
|
@@ -267,7 +333,7 @@ module ClientApiBuilder
|
|
267
333
|
end
|
268
334
|
|
269
335
|
self.class.headers.each(&add_header_proc)
|
270
|
-
options[:headers]
|
336
|
+
options[:headers] && options[:headers].each(&add_header_proc)
|
271
337
|
|
272
338
|
headers
|
273
339
|
end
|
@@ -281,6 +347,8 @@ module ClientApiBuilder
|
|
281
347
|
end
|
282
348
|
|
283
349
|
def build_query(query, options)
|
350
|
+
return nil if query.nil? && self.class.query_params.empty?
|
351
|
+
|
284
352
|
query_params = {}
|
285
353
|
|
286
354
|
add_query_param_proc = proc do |name, value|
|
@@ -295,22 +363,24 @@ module ClientApiBuilder
|
|
295
363
|
end
|
296
364
|
|
297
365
|
self.class.query_params.each(&add_query_param_proc)
|
298
|
-
query
|
366
|
+
query && query.each(&add_query_param_proc)
|
367
|
+
options[:query] && options[:query].each(&add_query_param_proc)
|
299
368
|
|
300
369
|
self.class.build_query(query_params)
|
301
370
|
end
|
302
371
|
|
303
372
|
def build_body(body, options)
|
304
|
-
|
373
|
+
body = options[:body] if options.key?(:body)
|
374
|
+
|
375
|
+
return nil unless body
|
305
376
|
return body if body.is_a?(String)
|
306
377
|
|
307
|
-
|
308
|
-
body.to_json
|
378
|
+
self.class.build_body(self, body, options)
|
309
379
|
end
|
310
380
|
|
311
381
|
def build_uri(path, query, options)
|
312
382
|
uri = URI(base_url(options) + path)
|
313
|
-
uri.query = build_query(query, options)
|
383
|
+
uri.query = build_query(query, options)
|
314
384
|
uri
|
315
385
|
end
|
316
386
|
|
@@ -342,5 +412,9 @@ module ClientApiBuilder
|
|
342
412
|
data
|
343
413
|
end
|
344
414
|
end
|
415
|
+
|
416
|
+
def root_router
|
417
|
+
self
|
418
|
+
end
|
345
419
|
end
|
346
420
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Purpose is to encapsulate adding nested routers
|
4
|
+
module ClientApiBuilder
|
5
|
+
module Section
|
6
|
+
def self.included(base)
|
7
|
+
base.extend ClassMethods
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def section(name, &block)
|
12
|
+
kls = InheritanceHelper::ClassBuilder::Utils.create_class(
|
13
|
+
self,
|
14
|
+
name,
|
15
|
+
::ClientApiBuilder::NestedRouter,
|
16
|
+
nil,
|
17
|
+
'NestedRouter',
|
18
|
+
&block
|
19
|
+
)
|
20
|
+
|
21
|
+
code = <<CODE
|
22
|
+
def self.#{name}_router
|
23
|
+
#{kls.name}
|
24
|
+
end
|
25
|
+
|
26
|
+
def #{name}
|
27
|
+
@#{name} ||= self.class.#{name}_router.new(self.root_router)
|
28
|
+
end
|
29
|
+
CODE
|
30
|
+
self.class_eval code, __FILE__, __LINE__
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/script/console
CHANGED
@@ -2,6 +2,9 @@
|
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
4
|
$LOAD_PATH << File.expand_path('../lib', __dir__)
|
5
|
+
$LOAD_PATH << File.expand_path('../examples', __dir__)
|
5
6
|
require 'client-api-builder'
|
7
|
+
autoload :BasicAuthExampleClient, 'basic_auth_example_client'
|
8
|
+
autoload :IMDBDatesetsClient, 'imdb_datasets_client'
|
6
9
|
require 'irb'
|
7
10
|
IRB.start(__FILE__)
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: client-api-builder
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Doug Youch
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
12
|
-
dependencies:
|
11
|
+
date: 2021-08-16 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: inheritance-helper
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.2.5
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.2.5
|
13
27
|
description: Utility for constructing API clients
|
14
28
|
email: dougyouch@gmail.com
|
15
29
|
executables: []
|
@@ -25,10 +39,13 @@ files:
|
|
25
39
|
- README.md
|
26
40
|
- client-api-builder.gemspec
|
27
41
|
- examples/basic_auth_example_client.rb
|
42
|
+
- examples/imdb_datasets_client.rb
|
28
43
|
- lib/client-api-builder.rb
|
44
|
+
- lib/client_api_builder/nested_router.rb
|
29
45
|
- lib/client_api_builder/net_http_request.rb
|
30
46
|
- lib/client_api_builder/query_params.rb
|
31
47
|
- lib/client_api_builder/router.rb
|
48
|
+
- lib/client_api_builder/section.rb
|
32
49
|
- script/console
|
33
50
|
homepage: https://github.com/dougyouch/client-api-builder
|
34
51
|
licenses:
|