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
@@ -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
|
+
|