joshua 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,33 @@
|
|
1
|
+
class Joshua
|
2
|
+
module Params
|
3
|
+
class Parse
|
4
|
+
ERRORS = {
|
5
|
+
en:{
|
6
|
+
bad_format: 'Bad value format',
|
7
|
+
not_integer: 'Not an integer',
|
8
|
+
min_value: 'Minimal allowed value is: %s',
|
9
|
+
max_value: 'Maximal allowed value is: %s',
|
10
|
+
email_min: 'Email requireds min of 8 characters',
|
11
|
+
email_missing: 'Email is missing @',
|
12
|
+
url_start: 'URL is not starting with http or https',
|
13
|
+
point_format: 'Point should be in format 1.2345678,1.2345678',
|
14
|
+
min_date: 'Minimal allow date is %s',
|
15
|
+
max_date: 'Maximal allow date is %s'
|
16
|
+
},
|
17
|
+
|
18
|
+
hr: {
|
19
|
+
bad_format: 'Format vrijednosti ne zadovoljava',
|
20
|
+
not_integer: 'Nije cijeli broj',
|
21
|
+
min_value: 'Minimalna dozvoljena vrijednost je: %s',
|
22
|
+
max_value: 'Maksimalna dozvoljena vrijednost je: %s',
|
23
|
+
email_min: 'Email zatjeva minimalno 8 znakova',
|
24
|
+
email_missing: 'U email-u nedostaje @',
|
25
|
+
url_start: 'URL ne započinje sa http ili https',
|
26
|
+
point_format: 'Geo točka bi trebala biti u formatu 1.2345678,1.2345678',
|
27
|
+
min_date: 'Minimalni dozvoljeni datum je %s',
|
28
|
+
max_date: 'Maksimalni dozvoljeni datum je %s'
|
29
|
+
}
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# Api response is constructed from this object
|
2
|
+
|
3
|
+
class Joshua
|
4
|
+
class Response
|
5
|
+
def initialize api
|
6
|
+
@api = api
|
7
|
+
@out = {}
|
8
|
+
@meta = {}
|
9
|
+
@errors = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def []= key, value
|
13
|
+
meta key, value
|
14
|
+
end
|
15
|
+
|
16
|
+
# forward header to rack_response.header
|
17
|
+
def header *args
|
18
|
+
if args.first
|
19
|
+
@api.rack_response.header[args.first] = args[1] if @api.rack_response
|
20
|
+
else
|
21
|
+
@api.rack_response.header
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# human readable response message
|
26
|
+
def message value
|
27
|
+
@message = value
|
28
|
+
end
|
29
|
+
|
30
|
+
# api meta response, any data is allowed
|
31
|
+
def meta key, value = nil
|
32
|
+
if value
|
33
|
+
@meta[key] = value
|
34
|
+
else
|
35
|
+
@meta[key]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# add api response error
|
40
|
+
def error *args
|
41
|
+
return @errors unless args[0]
|
42
|
+
|
43
|
+
desc, code = args.reverse
|
44
|
+
|
45
|
+
@errors[:code] = code if code
|
46
|
+
@errors[:messages] ||= []
|
47
|
+
@errors[:messages].push desc unless @errors[:messages].include?(desc)
|
48
|
+
end
|
49
|
+
|
50
|
+
def error?
|
51
|
+
!!(@errors[:messages] || @errors[:details])
|
52
|
+
end
|
53
|
+
|
54
|
+
def error_detail name, desc
|
55
|
+
error '%s (%s)' % [desc, name]
|
56
|
+
|
57
|
+
@errors[:details] ||= {}
|
58
|
+
@errors[:details][name] = desc
|
59
|
+
end
|
60
|
+
|
61
|
+
def data value
|
62
|
+
@data ||= value
|
63
|
+
end
|
64
|
+
|
65
|
+
def data?
|
66
|
+
!@data.nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
# render full api response
|
70
|
+
def render
|
71
|
+
{}.tap do |out|
|
72
|
+
if @errors.keys.empty?
|
73
|
+
out[:success] = true
|
74
|
+
else
|
75
|
+
out[:success] = false
|
76
|
+
out[:error] = @errors
|
77
|
+
end
|
78
|
+
|
79
|
+
out[:meta] = @meta
|
80
|
+
out[:message] = @message if @message
|
81
|
+
out[:data] = @data unless @data.nil?
|
82
|
+
out[:status] = error? ? 400 : 200
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# Coffee code example for chainable CleaApi access that is easy to read
|
2
|
+
# fell free to compile to JS here https://coffeescript.org/#try
|
3
|
+
#
|
4
|
+
# just execute action and trigger Info.api() buy default
|
5
|
+
# Api(path, opts)
|
6
|
+
#
|
7
|
+
# silent execute
|
8
|
+
# Api(path, opts).silent()
|
9
|
+
#
|
10
|
+
# trigger info and custom function on success
|
11
|
+
# Api(path, opts).done(function() { ... }) - info + done
|
12
|
+
#
|
13
|
+
# silent and redirect on sucess
|
14
|
+
# Api(path, opts).silent().redirect(path) - info and redirect on success
|
15
|
+
#
|
16
|
+
# silent on success, custom error func
|
17
|
+
# Api(path, opts).silent().error(error_func)
|
18
|
+
#
|
19
|
+
# real life example: update product, on success to not show info, just redirect to product page
|
20
|
+
# Api('product/123/update', {name: 'Foo', description: 'Bar', value: 5}).silent().follow()
|
21
|
+
|
22
|
+
window.Api = (path, opts={}) ->
|
23
|
+
if typeof(path) != 'string'
|
24
|
+
form = $ path
|
25
|
+
path = form.attr 'action'
|
26
|
+
opts = form.serializeHash()
|
27
|
+
|
28
|
+
path = "/api/#{path}" unless path.indexOf('/api/') == 0
|
29
|
+
request = new RequestBase()
|
30
|
+
|
31
|
+
$.ajax
|
32
|
+
type: 'POST'
|
33
|
+
url: path,
|
34
|
+
data: opts,
|
35
|
+
complete: request.complete
|
36
|
+
|
37
|
+
request
|
38
|
+
|
39
|
+
class RequestBase
|
40
|
+
refresh: (node) => @do_refresh = node || true; @
|
41
|
+
reload: => @do_reload = true; @
|
42
|
+
follow: (func) => @do_follow = true; @
|
43
|
+
error: (func) => @error_func = func; @
|
44
|
+
done: (func) => @done_func = func; @
|
45
|
+
redirect: (path) => @do_redirect = path; @
|
46
|
+
silent: (info) => @is_silent = [info]; @
|
47
|
+
complete: (data, http_status) =>
|
48
|
+
response = JSON.parse(data.responseText)
|
49
|
+
|
50
|
+
if response.error
|
51
|
+
if request.error_func
|
52
|
+
request.error_func(response.error)
|
53
|
+
else
|
54
|
+
Info.api(response)
|
55
|
+
|
56
|
+
if http_status == 'success'
|
57
|
+
Info.api(response) unless @is_silent
|
58
|
+
@done_func(response) if @done_func
|
59
|
+
Pjax.load(@do_redirect) if @do_redirect
|
60
|
+
Pjax.reload() if @do_reload
|
61
|
+
|
62
|
+
if node = @do_refresh
|
63
|
+
if typeof(node) == 'object'
|
64
|
+
Svelte('ajax', node).reload()
|
65
|
+
else
|
66
|
+
Pjax.refresh()
|
67
|
+
|
68
|
+
if @do_follow
|
69
|
+
location = data.getResponseHeader('location') || response.meta.path
|
70
|
+
|
71
|
+
if location
|
72
|
+
Pjax.load(location)
|
73
|
+
else
|
74
|
+
Info.error 'Follow URL not found'
|
75
|
+
|
data/lib/misc/doc.css
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
* { font-family: 'Inter', sans-serif; }
|
2
|
+
.gutter { margin-left: 20px; }
|
3
|
+
.sticky { position: -webkit-sticky; position: sticky; top: 0; }
|
4
|
+
.btn-outline-info svg { fill: #17a2b8; }
|
5
|
+
.btn-outline-info:hover svg { fill: #eee; }
|
6
|
+
button.request { float: right; display: none; }
|
7
|
+
.box { border-top: 1px solid #ccc; padding: 20px 20px 10px 20px; border-top-color: #ccc; background-color: #fff; margin-bottom: 20px; }
|
8
|
+
.box:hover button.request { display: inline; }
|
9
|
+
.anchor { display: block; position: relative; top: -95px; visibility: hidden; }
|
10
|
+
.btn { background-color: #fff; }
|
11
|
+
body { background-color: #f7f7f7; margin-bottom: 200px; }
|
12
|
+
header { background-color: #fff; box-shadow:0 1px 4px rgba(0,0,0,.15); }
|
13
|
+
pre { padding-bottom: 10px; font-family: inherit;}
|
14
|
+
h1.nav a { font-size: 22px; color: #383838; padding: 17px 0 18px 0; }
|
15
|
+
h5 { font-size: 18px; font-weight: 600; }
|
16
|
+
h5 a { color: #333 !important; }
|
17
|
+
h6 { color: #888; margin-bottom: 10px; -font-size: 13px; }
|
18
|
+
gray { color: #707070; }
|
19
|
+
b { color: #666975; }
|
20
|
+
svg { margin: 0 5px; }
|
21
|
+
bold { font-weight: 600; }
|
22
|
+
ul { margin-left: -10px; margin-top: -5px;}
|
23
|
+
form label { font-weight: bold; display: block; }
|
24
|
+
code { background: '#f8f8f8'; color: #e01e5a; border: 1px solid rgb(221, 221, 221); padding: 2px 7px; border-radius: 4px; font-size: 14px; font-family: Monospace; }
|
25
|
+
|
26
|
+
pre.code { font-family: monospace; font-size: 13px; background:#f7f7f7; padding: 10px 10px; margin-top: 15px; }
|
27
|
+
|
28
|
+
#modal table td { padding-bottom: 15px; vertical-align: top; }
|
29
|
+
#modal table td:first-child { padding: 10px 30px 0 0; }
|
data/lib/misc/doc.js
ADDED
@@ -0,0 +1,279 @@
|
|
1
|
+
// global state
|
2
|
+
window.State = {
|
3
|
+
active_tab: 'response',
|
4
|
+
request: {},
|
5
|
+
full_url: '',
|
6
|
+
|
7
|
+
set_request: (full_url) => {
|
8
|
+
State.full_url = full_url
|
9
|
+
|
10
|
+
parts = full_url.split('?')
|
11
|
+
parts[1] = parts[1] || ''
|
12
|
+
|
13
|
+
State.request = {
|
14
|
+
host: api_opts.mount_on,
|
15
|
+
path: parts[0].replace(api_opts.mount_on, ''),
|
16
|
+
params: parts[1],
|
17
|
+
object: {}
|
18
|
+
}
|
19
|
+
|
20
|
+
for (el of parts[1].split('&')) {
|
21
|
+
let [key, value] = el.split('=', 2)
|
22
|
+
State.request.object[key] = value
|
23
|
+
}
|
24
|
+
|
25
|
+
return State.request
|
26
|
+
},
|
27
|
+
}
|
28
|
+
|
29
|
+
// generic modal interface
|
30
|
+
window.Modal = {
|
31
|
+
render: (title, data) => {
|
32
|
+
$('#modal .modal-title').html(title)
|
33
|
+
$('#modal .modal-body').html(data)
|
34
|
+
$('#modal').show()
|
35
|
+
},
|
36
|
+
close: () => {
|
37
|
+
$('#modal').hide()
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
// form in modal box
|
42
|
+
window.ModalForm = {
|
43
|
+
render: (title, params) => {
|
44
|
+
let data = []
|
45
|
+
|
46
|
+
data.push(`<form onsubmit="TabResponse.render('${title}', this); return false;">`)
|
47
|
+
data.push(` <table>`)
|
48
|
+
|
49
|
+
if (title.includes('/:id/')) {
|
50
|
+
data.push(` <tr><td><label>ID</label></td><td><input id="api_id_value" type="text" class="form-control" value="" autocomplete="off" /></td></tr>`)
|
51
|
+
}
|
52
|
+
|
53
|
+
for (let [name, vals] of Object.entries(params)) {
|
54
|
+
data.push(` <tr>`)
|
55
|
+
data.push(` <td><label>${name}</label></td><td>`)
|
56
|
+
|
57
|
+
if (vals.type == 'boolean') {
|
58
|
+
data.push(` <input type="checkbox" class="form-control" name="${name}" />`)
|
59
|
+
} else {
|
60
|
+
data.push(` <input type="text" class="form-control" name="${name}" value="" autocomplete="off" />`)
|
61
|
+
}
|
62
|
+
|
63
|
+
if (vals.default) {
|
64
|
+
data.push(`<small class="form-text text-muted">default: ${vals.default}</small>`)
|
65
|
+
}
|
66
|
+
|
67
|
+
data.push(` </td></tr>`)
|
68
|
+
}
|
69
|
+
|
70
|
+
data.push(` <tr><td></td><td><button class="btn btn-outline-primary">Execute API request</button></td></tr>`)
|
71
|
+
data.push(` </table>`)
|
72
|
+
data.push(`</form>`)
|
73
|
+
|
74
|
+
data = `${data.join("\n")}<div id="api_result"></div>`
|
75
|
+
|
76
|
+
Modal.render(title, data);
|
77
|
+
}
|
78
|
+
}
|
79
|
+
|
80
|
+
// backend api call
|
81
|
+
window.TabResponse = {
|
82
|
+
render: (url, form) => {
|
83
|
+
let post = $(form).serialize()
|
84
|
+
let id_val = $('#api_id_value').val()
|
85
|
+
|
86
|
+
if (id_val) {
|
87
|
+
url = url.replace('/:id/', () => '/'+id_val+'/' )
|
88
|
+
}
|
89
|
+
|
90
|
+
full_url = url
|
91
|
+
if (post) full_url += `?${post}`
|
92
|
+
|
93
|
+
if (url.includes('/:id/')) {
|
94
|
+
alert('ID is not defined')
|
95
|
+
return
|
96
|
+
}
|
97
|
+
|
98
|
+
State.set_request(full_url)
|
99
|
+
|
100
|
+
TabResponse.render_tab_data()
|
101
|
+
|
102
|
+
let bearer = AuthButton.get()
|
103
|
+
if (bearer) {
|
104
|
+
post += post ? '&' : ''
|
105
|
+
post += 'api_token=' + bearer
|
106
|
+
}
|
107
|
+
|
108
|
+
$.post(url, post, (data) => {
|
109
|
+
State.response = data;
|
110
|
+
TabResponse.render_tab_data()
|
111
|
+
})
|
112
|
+
},
|
113
|
+
|
114
|
+
setActiveTab: (node, name) => {
|
115
|
+
let el = $(node)
|
116
|
+
// debugger
|
117
|
+
el.parents('ul').find('a').removeClass('active')
|
118
|
+
el.addClass('active')
|
119
|
+
|
120
|
+
State.active_tab = name
|
121
|
+
TabResponse.render_tab_data()
|
122
|
+
},
|
123
|
+
|
124
|
+
render_tab_data: () => {
|
125
|
+
let tabs = ['response', 'curl', 'javascript', 'ruby']
|
126
|
+
|
127
|
+
let out = []
|
128
|
+
out.push(`<div id="api_response"><br /><button class="btn btn-sm btn-outline-info" style="float: right; margin-top: -4px; margin-bottom: -30px;" onclick="$('#api_response').remove()">close</button>`)
|
129
|
+
|
130
|
+
out.push(`<ul class="nav nav-tabs" style="margin-left:0; margin-bottom: 15px;">`)
|
131
|
+
|
132
|
+
for (name of tabs) {
|
133
|
+
let is_active = State.active_tab == name ? ' active' : ''
|
134
|
+
out.push(`<li class="nav-item"><a class="nav-link ${is_active}" href="#" onclick="TabResponse.setActiveTab(this, '${name}'); return false;">${name}</a></li>`)
|
135
|
+
}
|
136
|
+
|
137
|
+
out.push(`</ul>`)
|
138
|
+
|
139
|
+
out.push(TabResponse.format[State.active_tab]())
|
140
|
+
$('#api_result').html(out.join("\n\n"))
|
141
|
+
},
|
142
|
+
|
143
|
+
format: {
|
144
|
+
response: () => {
|
145
|
+
let data = JSON.stringify(State.response || {}, null, 2)
|
146
|
+
let url = `<a href="${State.full_url}">${State.full_url}</a>`
|
147
|
+
|
148
|
+
return `<p>${url}</p><pre class="code">${data}</pre></div>`
|
149
|
+
},
|
150
|
+
|
151
|
+
curl: () => {
|
152
|
+
out = []
|
153
|
+
|
154
|
+
out.push(`# PS: you can send post data as JSON as well`)
|
155
|
+
out.push(`# JSON export is a default, "Accept: application/json" header is not needed`)
|
156
|
+
out.push(``)
|
157
|
+
|
158
|
+
out.push(`curl -s -X POST\\`)
|
159
|
+
|
160
|
+
let token = AuthButton.get()
|
161
|
+
if (token) {
|
162
|
+
out.push(` -H "Authorization: Bearer ${token}"\\`)
|
163
|
+
}
|
164
|
+
|
165
|
+
url = State.request
|
166
|
+
|
167
|
+
if (url.params) out.push(` --data '${url.params}'\\`)
|
168
|
+
|
169
|
+
out.push(` ${url.host}${url.path}`)
|
170
|
+
|
171
|
+
let parts = url.path.split('/')
|
172
|
+
|
173
|
+
let opts = {
|
174
|
+
id: 'foo-rand',
|
175
|
+
class: parts.shift(),
|
176
|
+
action: parts,
|
177
|
+
params: State.request.object,
|
178
|
+
token: token
|
179
|
+
}
|
180
|
+
|
181
|
+
out.push(``)
|
182
|
+
out.push(`# or json rpc style`)
|
183
|
+
out.push(`curl -s -X POST\\`)
|
184
|
+
out.push(` --data '${JSON.stringify(opts)}'\\`)
|
185
|
+
out.push(` ${url.host.replace(/\/$/, '')}`)
|
186
|
+
|
187
|
+
return `<pre class="code">${out.join("\n")}</pre></div>`
|
188
|
+
},
|
189
|
+
|
190
|
+
ruby: () => {
|
191
|
+
out = []
|
192
|
+
|
193
|
+
let params = JSON.stringify(State.request.object)
|
194
|
+
let parts = State.request.path.split('/')
|
195
|
+
|
196
|
+
out.push `# gem install 'joshua'`
|
197
|
+
out.push `require 'joshua/remote'\n`
|
198
|
+
|
199
|
+
out.push(`api = JoshuaRemote.new '${State.request.host.replace(/\/$/, '')}'`)
|
200
|
+
|
201
|
+
let token = AuthButton.get()
|
202
|
+
if (token) {
|
203
|
+
out.push(`api.auth_token = '${token}'`)
|
204
|
+
}
|
205
|
+
|
206
|
+
if (parts[2]) {
|
207
|
+
out.push(`api.${parts[0]}(${parts[1]}).${parts[2]}(${params})`)
|
208
|
+
out.push(`# or -> api.call('${parts[0]}/${parts[1]}/${parts[2]}', ${params})`)
|
209
|
+
out.push(`# or -> api.call(:${parts[0]}, ${parts[1]}, :${parts[2]}, ${params})`)
|
210
|
+
} else {
|
211
|
+
out.push(`api.${parts[0]}.${parts[1]}(${params})`)
|
212
|
+
out.push(`# or -> api.call('${parts[0]}/${parts[1]}', ${params})`)
|
213
|
+
out.push(`# or -> api.call(:${parts[0]}, :${parts[1]}, ${params})`)
|
214
|
+
}
|
215
|
+
|
216
|
+
out.push(`api.success?`)
|
217
|
+
out.push(`api.response`)
|
218
|
+
return `<pre class="code">${out.join("\n")}</pre></div>`
|
219
|
+
},
|
220
|
+
|
221
|
+
javascript: () => {
|
222
|
+
out = []
|
223
|
+
out.push(`const axios = require('axios').default;`)
|
224
|
+
out.push(``)
|
225
|
+
out.push(`axios.post(`)
|
226
|
+
out.push(` '${State.full_url.split('?')[0]}',`)
|
227
|
+
out.push(` ${JSON.stringify(State.request.object)},`)
|
228
|
+
|
229
|
+
let token = AuthButton.get()
|
230
|
+
if (token) {
|
231
|
+
out.push(` { headers: { Authorization: 'Bearer ${token}' } }`)
|
232
|
+
}
|
233
|
+
|
234
|
+
out.push(`).then((response) => { });`)
|
235
|
+
return `<pre class="code">${out.join("\n")}</pre></div>`
|
236
|
+
}
|
237
|
+
}
|
238
|
+
}
|
239
|
+
|
240
|
+
// auth botton
|
241
|
+
window.AuthButton = {
|
242
|
+
set: () => {
|
243
|
+
if (api_opts.bearer) {
|
244
|
+
Modal.render('Bearer token', api_opts.bearer)
|
245
|
+
} else {
|
246
|
+
let token = prompt('Bearer token?', AuthButton.get() || '')
|
247
|
+
|
248
|
+
if (token != null) {
|
249
|
+
localStorage.setItem('auth_token', token)
|
250
|
+
}
|
251
|
+
}
|
252
|
+
|
253
|
+
AuthButton.draw()
|
254
|
+
},
|
255
|
+
|
256
|
+
get: () => {
|
257
|
+
return api_opts.bearer || localStorage.getItem('auth_token')
|
258
|
+
},
|
259
|
+
|
260
|
+
draw: () => {
|
261
|
+
let value = AuthButton.get()
|
262
|
+
let text = value ? `Yes` : 'n/a'
|
263
|
+
|
264
|
+
$('#bearer_button').html(`Bearer Auth: <bold>${text}</bold>`)
|
265
|
+
}
|
266
|
+
}
|
267
|
+
|
268
|
+
AuthButton.draw()
|
269
|
+
|
270
|
+
|
271
|
+
// close dialog on escape
|
272
|
+
document.onkeydown = (evt) => {
|
273
|
+
evt = evt || window.event;
|
274
|
+
if (evt.keyCode == 27) {
|
275
|
+
Modal.close();
|
276
|
+
}
|
277
|
+
};
|
278
|
+
|
279
|
+
|