joshua 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/.version +1 -0
- data/lib/.DS_Store +0 -0
- data/lib/doc/doc.rb +273 -0
- data/lib/doc/special.rb +20 -0
- data/lib/joshua.rb +39 -0
- data/lib/joshua/base.rb +312 -0
- data/lib/joshua/error.rb +61 -0
- data/lib/joshua/opts.rb +204 -0
- data/lib/joshua/params/define.rb +53 -0
- data/lib/joshua/params/parse.rb +56 -0
- data/lib/joshua/params/types.rb +152 -0
- data/lib/joshua/params/types_errors.rb +33 -0
- data/lib/joshua/response.rb +86 -0
- data/lib/misc/api_example.coffee +75 -0
- data/lib/misc/doc.css +29 -0
- data/lib/misc/doc.js +279 -0
- data/lib/misc/favicon.png +0 -0
- data/lib/misc/ruby_client.rb +52 -0
- metadata +88 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3681a7c08c75032f3f6c574645e6a8375e5613d64c729a90119d3bc1c6d3e5be
|
4
|
+
data.tar.gz: 4cf6aeac3d58573b649f545ccdfae95f905c393ec476364989e8fb1fa0c38595
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9791cbbf644061bf19821093e7f91367466df6e77f66b8a0ef414f2cd5b5b0b4fa9a0c36125a64b3c874cf55e791a920ce8381a55b6bbc58b6a38e0aed791a1b
|
7
|
+
data.tar.gz: 3cf4e524aa396ee9c017c953f6eea4efc478df31160e1f96e92602a73ef9710cc896a297a1e7a6c3b77cbe2979571d51040471eef4e2a64bef47c1c5fb05dbed
|
data/.version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
data/lib/.DS_Store
ADDED
Binary file
|
data/lib/doc/doc.rb
ADDED
@@ -0,0 +1,273 @@
|
|
1
|
+
class Joshua
|
2
|
+
module Doc
|
3
|
+
extend self
|
4
|
+
|
5
|
+
ICONS = {
|
6
|
+
github: {
|
7
|
+
url: 'https://github.com/dux/joshua',
|
8
|
+
image: '<path d="M11.999 1.271C5.925 1.271 1 6.196 1 12.273c0 4.859 3.152 8.982 7.523 10.437.55.1.751-.239.751-.53l-.015-1.872c-3.06.666-3.706-1.474-3.706-1.474-.5-1.271-1.221-1.609-1.221-1.609-.999-.683.075-.668.075-.668 1.105.077 1.685 1.133 1.685 1.133.981 1.681 2.575 1.196 3.202.914.1-.711.384-1.196.698-1.471-2.442-.277-5.011-1.221-5.011-5.436 0-1.201.429-2.183 1.133-2.952-.114-.278-.491-1.397.108-2.911 0 0 .923-.296 3.025 1.127A10.56 10.56 0 0 1 12 6.591c.935.004 1.876.127 2.754.37 2.1-1.423 3.022-1.127 3.022-1.127.6 1.514.223 2.633.11 2.911.705.769 1.131 1.751 1.131 2.952 0 4.225-2.573 5.155-5.023 5.427.395.34.747 1.011.747 2.038 0 1.471-.014 2.657-.014 3.018 0 .293.199.636.756.528C19.851 21.251 23 17.13 23 12.273c0-6.077-4.926-11.002-11.001-11.002z"></path>',
|
9
|
+
},
|
10
|
+
twitter: {
|
11
|
+
url: 'https://twitter.com/@dux',
|
12
|
+
image: '<path d="M22.208 3.871c-.757.252-1.824.496-2.834.748-.757-.883-1.892-1.388-3.154-1.388-2.902 0-4.92 2.649-4.289 5.425-3.659-.126-6.939-1.892-9.083-4.542-1.135 1.892-.505 4.542 1.388 5.803-.757 0-1.388-.126-2.019-.505 0 2.145 1.388 4.037 3.532 4.416-.631.252-1.388.252-2.019.126.505 1.766 2.145 3.028 4.037 3.028-1.892 1.388-4.289 2.019-6.56 1.766 1.892 1.262 4.163 2.019 6.686 2.019 8.2 0 12.742-6.813 12.49-12.994.753-1.089 1.49-2.201 1.824-3.902z"></path>',
|
13
|
+
},
|
14
|
+
email: {
|
15
|
+
url: 'mailto:reic.dino@gmail.com',
|
16
|
+
image: '<path d="M22.22 9.787c0-5.13-4.062-8.307-8.983-8.307-6.666 0-11.457 4.948-11.457 11.431 0 6.042 4.609 9.609 9.999 9.609 1.64 0 3.749-.391 5.234-1.12l.364-2.031c-1.484.729-3.645 1.224-5.442 1.224-4.661 0-7.968-2.968-7.968-7.682 0-5.025 3.619-9.478 9.14-9.478 3.854 0 7.004 2.318 7.004 6.354 0 1.562-.39 3.671-1.588 4.843-.521.521-1.068.885-1.849.885-.599 0-1.015-.312-1.015-1.015 0-.235.052-.495.104-.729l1.614-6.458h-1.745l-.65 1.094c-.521-.938-1.615-1.381-2.63-1.381-3.386 0-5.208 3.151-5.208 6.25 0 1.198.416 2.291 1.197 3.047.599.598 1.485 1.041 2.5 1.041 1.328 0 2.422-.443 3.307-1.458.209.729 1.042 1.458 2.292 1.458 1.64 0 2.578-.625 3.541-1.562 1.536-1.484 2.239-3.828 2.239-6.015zm-7.916 1.276c0 1.77-.755 4.426-2.916 4.426-1.458 0-2.057-1.067-2.057-2.395 0-1.094.365-2.474 1.172-3.385.442-.495 1.015-.886 1.718-.886 1.406 0 2.083.886 2.083 2.24z"></path>',
|
17
|
+
},
|
18
|
+
error: {
|
19
|
+
image: '<path d="M3,4v12c0,1.103,0.897,2,2,2h3.5l3.5,4l3.5-4H19c1.103,0,2-0.897,2-2V4c0-1.103-0.897-2-2-2H5C3.897,2,3,2.897,3,4z M11,5 h2v6h-2V5z M11,13h2v2h-2V13z" />'
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
def tag
|
24
|
+
HtmlTagBuilder
|
25
|
+
end
|
26
|
+
|
27
|
+
def misc_file name
|
28
|
+
File.read [__dir__, '../misc/%s' % name].join('/')
|
29
|
+
end
|
30
|
+
|
31
|
+
# render full page
|
32
|
+
def render mount_on: nil, request: nil, bearer: nil
|
33
|
+
mount_on ||= request.url.split('?').first+'/'
|
34
|
+
mount_on.sub! %r{//$}, '/'
|
35
|
+
|
36
|
+
tag.html do |n|
|
37
|
+
n.head do |n|
|
38
|
+
n.title 'Joshua Tester'
|
39
|
+
n.link({ href: "https://fonts.googleapis.com/css?family=Inter:300,400,500,600,700,800,900&display=swap", rel:"stylesheet" })
|
40
|
+
n.link({ rel:"stylesheet", href:"https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" })
|
41
|
+
n.script({ src: 'https://cdnjs.cloudflare.com/ajax/libs/zepto/1.2.0/zepto.min.js' })
|
42
|
+
n.script %[window.api_opts = { mount_on: '#{mount_on}', bearer: '#{bearer}' }]
|
43
|
+
end
|
44
|
+
n.body do |n|
|
45
|
+
n.style { misc_file('doc.css') }
|
46
|
+
n.header({ style: 'border-bottom: 1px solid rgb(228, 228, 228);'}) do |n|
|
47
|
+
n._container do |n|
|
48
|
+
n.push top_icons
|
49
|
+
n.push %[<button id="bearer_button" onclick="AuthButton.set()" class="btn btn-sm btn-outline-primary" style="float: right; margin-top: 15px; margin-right: 20px;">-</button>]
|
50
|
+
n.h1({ class: :nav}) { %[<a href="#top">Joshua <gray>Docs</gray></a>] }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
n.push modal_dialog
|
55
|
+
|
56
|
+
n._container do |n|
|
57
|
+
n._row do |n|
|
58
|
+
n._col_3 do |n|
|
59
|
+
n._sticky(style: 'padding-top: 30px;') do |n|
|
60
|
+
n.a({ class: :dark, href: '#top' }) { '<p><b>API OBJECTS</b></p>' }
|
61
|
+
n.push left_nav
|
62
|
+
|
63
|
+
n.br
|
64
|
+
n.br
|
65
|
+
|
66
|
+
n.p '<b>TOOLS</b>'
|
67
|
+
n.div do |n|
|
68
|
+
n.push %[<p><a class="badge badge-light" href="#api_errors">Named errors</a></p>]
|
69
|
+
n.push %[<p><a class="badge badge-light" href="#{mount_on}_/postman" target="capi_postman">Postman import URL</a></p>]
|
70
|
+
n.push %[<p><a class="badge badge-light" href="#{mount_on}_/raw" target="capi_raw">Raw doc data</a></p>]
|
71
|
+
end
|
72
|
+
|
73
|
+
n.br
|
74
|
+
|
75
|
+
n.p '<b>API LIBRARIES</b>'
|
76
|
+
n.div do |n|
|
77
|
+
n.push %[<a class="badge badge-light" href="https://github.com/dux/joshua/blob/master/lib/misc/ruby_client.rb" target="capi_ruby">Ruby</a>]
|
78
|
+
n.push %[<a class="badge badge-light" href="https://github.com/dux/joshua/blob/master/lib/misc/api_example.coffee" target="capi_js">Javascript</a>]
|
79
|
+
n.push %[<a class="badge badge-light" href="#">Python</a>]
|
80
|
+
n.push %[<a class="badge badge-light" href="#">C#</a>]
|
81
|
+
end
|
82
|
+
|
83
|
+
n.br
|
84
|
+
|
85
|
+
n.p '<b>RESOURCES</b>'
|
86
|
+
n.div do |n|
|
87
|
+
n.push %[<a class="badge badge-light" href="http://vmrcre.org/web/scribe/home/-/blogs/why-rest-sucks" target="capi_why">Why we only prefer POST?</a>]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
n._col_9 do |n|
|
93
|
+
n.push index
|
94
|
+
n.push list_errors
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# anchor link
|
103
|
+
def name_link name, top=nil
|
104
|
+
%[<a name="#{name}" class="anchor" style="top: #{top || -95}px;"></a>]
|
105
|
+
end
|
106
|
+
|
107
|
+
# render single icon
|
108
|
+
def icon data, size: 24, color: nil, style: nil
|
109
|
+
%[<svg style="width: #{size}px; height: #{size}px; #{style}" viewBox="0 0 24 24" fill="currentColor">#{data}</svg>]
|
110
|
+
end
|
111
|
+
|
112
|
+
# top side navigation icons
|
113
|
+
def top_icons
|
114
|
+
tag.div({ style: 'float: right; margin-top: 18px;' }) do |n|
|
115
|
+
for icon in ICONS.values
|
116
|
+
next unless icon[:url]
|
117
|
+
n.push %[<a target="_new" href="#{icon[:url]}">#{icon icon[:image]}</a>]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# left side navigation
|
123
|
+
def left_nav
|
124
|
+
tag.div do |n|
|
125
|
+
Joshua.documented.each do |name|
|
126
|
+
n.a({ class:'btn btn-outline-info btn-sm', style: '-font-size: 14px; margin-bottom: 10px;', href: '#%s' % name}) do |n|
|
127
|
+
icon = name.opts.dig(:opts, :icon)
|
128
|
+
n.push self.icon icon, size: 20 if icon
|
129
|
+
n.push name.to_s.sub(/Api$/, '')
|
130
|
+
end
|
131
|
+
|
132
|
+
n.br
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# render doc for all documented classes
|
138
|
+
def index
|
139
|
+
tag.div do |n|
|
140
|
+
for @klass in Joshua.documented
|
141
|
+
@opts = @klass.opts
|
142
|
+
icon = @opts.dig(:opts, :icon)
|
143
|
+
|
144
|
+
n._sticky(style: 'background: #f7f7f7; padding-bottom: 5px; padding-top: 30px; margin-top: 2px;') do |n|
|
145
|
+
n.push name_link @klass, 40
|
146
|
+
n.push self.icon icon, style: 'position: absolute; margin-left: -40px; margin-top: 1px; fill: #777; background: #f7f7f7;' if icon
|
147
|
+
n.h4 { @klass.to_s.sub(/Api$/, '') }
|
148
|
+
end
|
149
|
+
|
150
|
+
if desc = @opts.dig(:opts, :desc)
|
151
|
+
n.p { desc }
|
152
|
+
end
|
153
|
+
|
154
|
+
if detail = @opts.dig(:opts, :detail)
|
155
|
+
n.p { detail }
|
156
|
+
end
|
157
|
+
|
158
|
+
n.push render_type :member
|
159
|
+
n.push render_type :collection
|
160
|
+
|
161
|
+
n.br
|
162
|
+
n.hr
|
163
|
+
n.br
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# render members or collection
|
169
|
+
def render_type name
|
170
|
+
base = @opts[name] || return
|
171
|
+
|
172
|
+
tag.div do |n|
|
173
|
+
n.br
|
174
|
+
n.h5 '<gray>%s methods</gray>' % name
|
175
|
+
|
176
|
+
for m_name, member in base
|
177
|
+
n.div do |n|
|
178
|
+
n.push render_method name: name, m_name: m_name, opts: member
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# render api method
|
185
|
+
def render_method name:, m_name:, opts:
|
186
|
+
tag._box do |n|
|
187
|
+
# n.push %[<button onclick="" class="btn btn-info btn-sm request">request</button>]
|
188
|
+
anchor = [@klass, m_name].join('-')
|
189
|
+
|
190
|
+
n.push name_link anchor
|
191
|
+
n.h5 do |n|
|
192
|
+
n.push "<a href='##{anchor}'>#{m_name}</a>"
|
193
|
+
n.push ' <gray> — %s</gray>' % opts[:desc] if opts[:desc]
|
194
|
+
end
|
195
|
+
|
196
|
+
n.p({style: 'margin: 20px 0 25px 0;'}) do |n|
|
197
|
+
path = @klass.api_path
|
198
|
+
path += '/:id' if name == :member
|
199
|
+
path += "/#{m_name}"
|
200
|
+
n.push %[<button href="#{path}" class="btn btn-outline-info btn-sm" onclick="ModalForm.render(api_opts.mount_on+this.innerHTML, #{(opts[:params] || {}).to_json.gsub('"', '"')})">#{path}</button>]
|
201
|
+
end
|
202
|
+
|
203
|
+
if opts[:detail]
|
204
|
+
n.h6 'Details'
|
205
|
+
n.pre opts[:detail]
|
206
|
+
end
|
207
|
+
|
208
|
+
if mopts = opts[:params]
|
209
|
+
n.h6 'Params'
|
210
|
+
n.ul do |n|
|
211
|
+
for name, opt in mopts
|
212
|
+
n.li do |n|
|
213
|
+
n.push '<bold>%s</bold>: ' % name
|
214
|
+
n.push opt[:type]
|
215
|
+
|
216
|
+
data = []
|
217
|
+
data.push 'required' if opt[:required]
|
218
|
+
data.push 'default: %s' % opt[:default].to_s unless opt[:default].nil?
|
219
|
+
n.push ' — (%s)' % data.join(', ') if data.length > 0
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def list_errors
|
228
|
+
tag.div do |n|
|
229
|
+
n.push name_link :api_errors
|
230
|
+
n.push icon ICONS[:error][:image], style: 'position: absolute; margin-left: -40px; margin-top: 1px; fill: #777;'
|
231
|
+
n.h4 { 'Named errors' }
|
232
|
+
|
233
|
+
n._box do |n|
|
234
|
+
if RESCUE_FROM.keys.length == 0
|
235
|
+
n.p 'No named errors defiend via'
|
236
|
+
n.code "rescue from :name, 'Error description'"
|
237
|
+
end
|
238
|
+
|
239
|
+
n._row({ style: 'margin-bottom: 30px;' }) do |n|
|
240
|
+
for key, desc in RESCUE_FROM
|
241
|
+
next if key == :all
|
242
|
+
next unless key.is_a?(Symbol) && desc.is_a?(String)
|
243
|
+
|
244
|
+
n._col_4 { "<code>#{key}</code>" }
|
245
|
+
n._col_8 { desc }
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def modal_dialog
|
253
|
+
%[
|
254
|
+
<script>#{misc_file('doc.js')}</script>
|
255
|
+
<div id="modal" class="modal" tabindex="-1" role="dialog">
|
256
|
+
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(99,99,99,0.3)"></div>
|
257
|
+
<div class="modal-dialog modal-lg" role="document">
|
258
|
+
<div class="modal-content">
|
259
|
+
<div class="modal-header">
|
260
|
+
<h5 class="modal-title"></h5>
|
261
|
+
<button type="button" class="close" data-dismiss="modal" aria-label="Close" onclick="Modal.close()">
|
262
|
+
<span aria-hidden="true">×</span>
|
263
|
+
</button>
|
264
|
+
</div>
|
265
|
+
<div class="modal-body">
|
266
|
+
</div>
|
267
|
+
</div>
|
268
|
+
</div>
|
269
|
+
</div>
|
270
|
+
]
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
data/lib/doc/special.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# reponse from /api/_/foo
|
2
|
+
|
3
|
+
class Joshua
|
4
|
+
module DocSpecial
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def postman
|
8
|
+
raw
|
9
|
+
end
|
10
|
+
|
11
|
+
def raw
|
12
|
+
unwanted = %w(all member collection)
|
13
|
+
{}.tap do |doc|
|
14
|
+
for el in Joshua.documented
|
15
|
+
doc[el.to_s.sub(/Api$/, '').tableize] = el.opts.filter { |k, _| !unwanted.include?(k.to_s.split('_')[1]) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/joshua.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
unless ''.respond_to?(:dasherize)
|
2
|
+
require 'dry/inflector'
|
3
|
+
|
4
|
+
class String
|
5
|
+
%w(
|
6
|
+
classify
|
7
|
+
constantize
|
8
|
+
dasherize
|
9
|
+
ordinalize
|
10
|
+
pluralize
|
11
|
+
singularize
|
12
|
+
tableize
|
13
|
+
underscore
|
14
|
+
).each do |name|
|
15
|
+
define_method name do
|
16
|
+
Dry::Inflector.new.send(name, self)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'json'
|
23
|
+
require 'html-tag'
|
24
|
+
require 'clean-hash'
|
25
|
+
|
26
|
+
require_relative './joshua/params/define'
|
27
|
+
require_relative './joshua/params/parse'
|
28
|
+
require_relative './joshua/params/types'
|
29
|
+
require_relative './joshua/params/types_errors'
|
30
|
+
require_relative './joshua/opts'
|
31
|
+
require_relative './joshua/base'
|
32
|
+
require_relative './joshua/error'
|
33
|
+
require_relative './joshua/response'
|
34
|
+
|
35
|
+
require_relative './doc/doc'
|
36
|
+
require_relative './doc/special'
|
37
|
+
|
38
|
+
|
39
|
+
|
data/lib/joshua/base.rb
ADDED
@@ -0,0 +1,312 @@
|
|
1
|
+
class Joshua
|
2
|
+
INSTANCE ||= Struct.new 'JoshuaOpts',
|
3
|
+
:action,
|
4
|
+
:bearer,
|
5
|
+
:development,
|
6
|
+
:id,
|
7
|
+
:method_opts,
|
8
|
+
:opts,
|
9
|
+
:params,
|
10
|
+
:raw,
|
11
|
+
:rack_response,
|
12
|
+
:request,
|
13
|
+
:response,
|
14
|
+
:uid
|
15
|
+
|
16
|
+
attr_reader :api
|
17
|
+
|
18
|
+
class << self
|
19
|
+
# here we capture member & collection metods
|
20
|
+
def method_added name
|
21
|
+
return if name.to_s.start_with?('_api_')
|
22
|
+
return unless @method_type
|
23
|
+
|
24
|
+
set @method_type, name, PARAMS.fetch_and_clear_opts
|
25
|
+
|
26
|
+
alias_method "_api_#{@method_type}_#{name}", name
|
27
|
+
remove_method name
|
28
|
+
end
|
29
|
+
|
30
|
+
# perform auto_mount from a rake call
|
31
|
+
def call env
|
32
|
+
request = Rack::Request.new env
|
33
|
+
|
34
|
+
if request.path == '/favicon.ico'
|
35
|
+
[
|
36
|
+
200,
|
37
|
+
{ 'Cache-Control'=>'public; max-age=1000000' },
|
38
|
+
[Doc.misc_file('favicon.png')]
|
39
|
+
]
|
40
|
+
else
|
41
|
+
data = auto_mount request: request, mount_on: '/', development: ENV['RACK_ENV'] == 'development'
|
42
|
+
|
43
|
+
if data.is_a?(Hash)
|
44
|
+
[
|
45
|
+
200,
|
46
|
+
{ 'Content-Type' => 'application/json', 'Cache-Control'=>'private; max-age=0' },
|
47
|
+
[data.to_json]
|
48
|
+
]
|
49
|
+
else
|
50
|
+
data = data.to_s
|
51
|
+
[
|
52
|
+
200,
|
53
|
+
{ 'Content-Type' => 'text/html', 'Cache-Control'=>'public; max-age=3600' },
|
54
|
+
[data]
|
55
|
+
]
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# ApplicationApi.auto_mount request: request, response: response, mount_on: '/api', development: true
|
61
|
+
# auto mount to a root
|
62
|
+
# * display doc in a root
|
63
|
+
# * call methods if possible /api/v1.comapny/1/show
|
64
|
+
def auto_mount request:, response: nil, mount_on: nil, bearer: nil, development: false
|
65
|
+
mount_on = [request.base_url, mount_on].join('') unless mount_on.to_s.include?('//')
|
66
|
+
|
67
|
+
if request.url == mount_on && request.request_method == 'GET'
|
68
|
+
response.header['Content-Type'] = 'text/html' if response
|
69
|
+
|
70
|
+
Doc.render request: request, bearer: bearer
|
71
|
+
else
|
72
|
+
response.header['Content-Type'] = 'application/json' if response
|
73
|
+
|
74
|
+
body = request.body.read.to_s
|
75
|
+
body = body[0] == '{' ? JSON.parse(body) : nil
|
76
|
+
|
77
|
+
# class: klass, params: params, bearer: bearer, request: request, response: response, development: development
|
78
|
+
opts = {}
|
79
|
+
opts[:request] = request
|
80
|
+
opts[:response] = response
|
81
|
+
opts[:development] = development
|
82
|
+
opts[:bearer] = bearer
|
83
|
+
|
84
|
+
action =
|
85
|
+
if body
|
86
|
+
# {
|
87
|
+
# "id": 'foo', # unique ID that will be returned, as required by JSON RPC spec
|
88
|
+
# "class": 'v1/users', # v1/users => V1::UsersApi
|
89
|
+
# "action": 'index', # "index' or "6/info" or [6, "info"]
|
90
|
+
# "token": 'ab12ef', # api_token (bearer)
|
91
|
+
# "params": {} # methos params
|
92
|
+
# }
|
93
|
+
opts[:params] = body['params'] || {}
|
94
|
+
opts[:bearer] = body['token'] if body['token']
|
95
|
+
opts[:class] = body['class']
|
96
|
+
|
97
|
+
body['action']
|
98
|
+
else
|
99
|
+
opts[:params] = request.params || {}
|
100
|
+
opts[:bearer] = opts[:params][:api_token] if opts[:params][:api_token]
|
101
|
+
|
102
|
+
mount_on = mount_on+'/' unless mount_on.end_with?('/')
|
103
|
+
path = request.url.split(mount_on, 2).last.split('?').first.to_s
|
104
|
+
parts = path.split('/')
|
105
|
+
|
106
|
+
opts[:class] = parts.shift
|
107
|
+
parts
|
108
|
+
end
|
109
|
+
|
110
|
+
opts[:bearer] ||= request.env['HTTP_AUTHORIZATION'].to_s.split('Bearer ')[1]
|
111
|
+
|
112
|
+
api_response = render action, **opts
|
113
|
+
|
114
|
+
if api_response.is_a?(Hash)
|
115
|
+
response.status = api_response[:status] if response
|
116
|
+
api_response.to_h
|
117
|
+
else
|
118
|
+
api_response
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def render action, opts={}
|
124
|
+
return error 'Action not defined' unless action[0]
|
125
|
+
|
126
|
+
api_class =
|
127
|
+
if klass = opts.delete(:class)
|
128
|
+
# /api/_/foo
|
129
|
+
if klass == '_'
|
130
|
+
if Joshua::DocSpecial.respond_to?(action.first)
|
131
|
+
return Joshua::DocSpecial.send action.first.to_sym
|
132
|
+
else
|
133
|
+
return error 'Action %s not defined' % action.first
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
klass = klass.split('/') if klass.is_a?(String)
|
138
|
+
klass[klass.length-1] += '_api'
|
139
|
+
|
140
|
+
begin
|
141
|
+
klass.join('/').classify.constantize
|
142
|
+
rescue NameError => e
|
143
|
+
return error 'API class "%s" not found' % klass
|
144
|
+
end
|
145
|
+
else
|
146
|
+
self
|
147
|
+
end
|
148
|
+
|
149
|
+
api = api_class.new action, **opts
|
150
|
+
api.execute_call
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
def only_in_api_methods!
|
156
|
+
raise ArgumentError, "Available only inside collection or member block for API methods." unless @method_type
|
157
|
+
end
|
158
|
+
|
159
|
+
def set_callback name, block
|
160
|
+
name = [name, @method_type || :all].join('_').to_sym
|
161
|
+
set name, []
|
162
|
+
OPTS[to_s][name].push block
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
###
|
167
|
+
|
168
|
+
def initialize action, id: nil, bearer: nil, params: {}, opts: {}, request: nil, response: nil, development: false
|
169
|
+
@api = INSTANCE.new
|
170
|
+
|
171
|
+
if action.is_a?(Array)
|
172
|
+
# unpack id and action is action is given in path form # [123, :show]
|
173
|
+
@api.id, @api.action = action[1] ? action : [nil, action[0]]
|
174
|
+
else
|
175
|
+
@api.action = action
|
176
|
+
end
|
177
|
+
|
178
|
+
@api.bearer = bearer
|
179
|
+
@api.id ||= id
|
180
|
+
@api.action = @api.action.to_sym
|
181
|
+
@api.request = request
|
182
|
+
@api.method_opts = self.class.opts.dig(@api.id ? :member : :collection, @api.action) || {}
|
183
|
+
@api.development = !!development
|
184
|
+
@api.rack_response = response
|
185
|
+
@api.params = ::CleanHash::Indifferent.new params
|
186
|
+
@api.opts = ::CleanHash::Indifferent.new opts
|
187
|
+
@api.response = ::Joshua::Response.new @api
|
188
|
+
end
|
189
|
+
|
190
|
+
def message data
|
191
|
+
response.message data
|
192
|
+
end
|
193
|
+
|
194
|
+
def execute_call
|
195
|
+
if !@api.development && @api.request && @api.request_method == 'GET' && !@api.method_opts[:gettable]
|
196
|
+
response.error 'GET request is not allowed'
|
197
|
+
else
|
198
|
+
parse_api_params
|
199
|
+
parse_annotations unless response.error?
|
200
|
+
resolve_api_body unless response.error?
|
201
|
+
end
|
202
|
+
|
203
|
+
@api.raw || response.render
|
204
|
+
end
|
205
|
+
|
206
|
+
def resolve_api_body &block
|
207
|
+
begin
|
208
|
+
# execute before "in the wild"
|
209
|
+
# model @api.pbject should be set here
|
210
|
+
execute_callback :before_all
|
211
|
+
|
212
|
+
instance_exec &block if block
|
213
|
+
|
214
|
+
# if we have model defiend, we execute member otherwise collection
|
215
|
+
type = @api.id ? :member : :collection
|
216
|
+
|
217
|
+
execute_callback 'before_%s' % type
|
218
|
+
api_method = '_api_%s_%s' % [type, @api.action]
|
219
|
+
raise Joshua::Error, "Api method #{type}:#{@api.action} not found" unless respond_to?(api_method)
|
220
|
+
data = send api_method
|
221
|
+
response.data data unless response.data?
|
222
|
+
|
223
|
+
# after blocks
|
224
|
+
execute_callback 'after_%s' % type
|
225
|
+
rescue Joshua::Error => error
|
226
|
+
# controlled error raised via error "message", ignore
|
227
|
+
response.error error.message
|
228
|
+
rescue => error
|
229
|
+
Joshua.error_print error
|
230
|
+
|
231
|
+
block = RESCUE_FROM[error.class] || RESCUE_FROM[:all]
|
232
|
+
|
233
|
+
if block
|
234
|
+
instance_exec error, &block
|
235
|
+
else
|
236
|
+
# uncontrolled error, should be logged
|
237
|
+
# search to response[:code] 500 in after block
|
238
|
+
response.error error.message
|
239
|
+
response.error :class, error.class.to_s
|
240
|
+
response.error :code, 500
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
# we execute generic after block in case of error or no
|
245
|
+
execute_callback :after_all
|
246
|
+
end
|
247
|
+
|
248
|
+
def to_json
|
249
|
+
execute_call.to_json
|
250
|
+
end
|
251
|
+
|
252
|
+
def to_h
|
253
|
+
execute_call
|
254
|
+
end
|
255
|
+
|
256
|
+
private
|
257
|
+
|
258
|
+
def parse_api_params
|
259
|
+
return unless @api.method_opts[:params]
|
260
|
+
|
261
|
+
parse = Joshua::Params::Parse.new
|
262
|
+
|
263
|
+
for name, opts in @api.method_opts[:params]
|
264
|
+
# enforce required
|
265
|
+
if opts[:required] && @api.params[name].to_s == ''
|
266
|
+
response.error_detail name, 'Argument missing'
|
267
|
+
next
|
268
|
+
end
|
269
|
+
|
270
|
+
begin
|
271
|
+
# check and coerce value
|
272
|
+
@api.params[name] = parse.check opts[:type], @api.params[name], opts
|
273
|
+
rescue Joshua::Error => error
|
274
|
+
# add to details if error found
|
275
|
+
response.error_detail name, error.message
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def parse_annotations
|
281
|
+
for key, opts in (@api.method_opts[:annotations] || {})
|
282
|
+
instance_exec *opts, &ANNOTATIONS[key]
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
def execute_callback name
|
287
|
+
self.class.ancestors.reverse.map(&:to_s).each do |klass|
|
288
|
+
if before_list = (OPTS.dig(klass, name.to_sym) || [])
|
289
|
+
for before in before_list
|
290
|
+
instance_exec &before
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def response content_type=nil
|
297
|
+
if block_given?
|
298
|
+
@api.raw = yield
|
299
|
+
|
300
|
+
if @api.rack_response
|
301
|
+
@api.rack_response.header['Content-Type'] = content_type || (@api.raw[0] == '{' ? 'application/json' : 'text/plain')
|
302
|
+
end
|
303
|
+
else
|
304
|
+
@api.response
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def params
|
309
|
+
@api.params
|
310
|
+
end
|
311
|
+
|
312
|
+
end
|