cf-uaac 1.3.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.
- data/.gitignore +8 -0
- data/Gemfile +16 -0
- data/README.md +48 -0
- data/Rakefile +50 -0
- data/bin/completion-helper +80 -0
- data/bin/uaac +5 -0
- data/bin/uaac-completion.sh +34 -0
- data/bin/uaas +7 -0
- data/cf-uaac.gemspec +48 -0
- data/lib/cli.rb +15 -0
- data/lib/cli/base.rb +277 -0
- data/lib/cli/client_reg.rb +103 -0
- data/lib/cli/common.rb +187 -0
- data/lib/cli/config.rb +163 -0
- data/lib/cli/favicon.ico +0 -0
- data/lib/cli/group.rb +85 -0
- data/lib/cli/info.rb +54 -0
- data/lib/cli/runner.rb +52 -0
- data/lib/cli/token.rb +217 -0
- data/lib/cli/user.rb +108 -0
- data/lib/cli/version.rb +18 -0
- data/lib/stub/scim.rb +387 -0
- data/lib/stub/server.rb +310 -0
- data/lib/stub/uaa.rb +485 -0
- data/spec/client_reg_spec.rb +104 -0
- data/spec/common_spec.rb +89 -0
- data/spec/group_spec.rb +93 -0
- data/spec/http_spec.rb +165 -0
- data/spec/info_spec.rb +74 -0
- data/spec/spec_helper.rb +87 -0
- data/spec/token_spec.rb +119 -0
- data/spec/user_spec.rb +61 -0
- metadata +292 -0
data/lib/cli/version.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
module CF
|
15
|
+
module UAA
|
16
|
+
CLI_VERSION = "1.3.0"
|
17
|
+
end
|
18
|
+
end
|
data/lib/stub/scim.rb
ADDED
@@ -0,0 +1,387 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'set'
|
15
|
+
require 'time'
|
16
|
+
require 'uaa/util'
|
17
|
+
|
18
|
+
module CF::UAA
|
19
|
+
|
20
|
+
class SchemaViolation < RuntimeError; end
|
21
|
+
class AlreadyExists < RuntimeError; end
|
22
|
+
class BadFilter < RuntimeError; end
|
23
|
+
class BadVersion < RuntimeError; end
|
24
|
+
|
25
|
+
class StubScim
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# attribute types. Anything not listed is case-ignore string
|
30
|
+
HIDDEN_ATTRS = [:rtype, :password, :client_secret].to_set
|
31
|
+
READ_ONLY_ATTRS = [:rtype, :id, :meta, :groups].to_set
|
32
|
+
BOOLEANS = [:active].to_set
|
33
|
+
NUMBERS = [:access_token_validity, :refresh_token_validity].to_set
|
34
|
+
GROUPS = [:groups, :auto_approved_scope, :scope, :authorities].to_set
|
35
|
+
REFERENCES = [*GROUPS, :members, :owners, :readers].to_set # users or groups
|
36
|
+
ENUMS = { authorized_grant_types: ["client_credentials", "implicit",
|
37
|
+
"authorization_code", "password", "refresh_token"].to_set }
|
38
|
+
GENERAL_MULTI = [:emails, :phonenumbers, :ims, :photos, :entitlements,
|
39
|
+
:roles, :x509certificates].to_set
|
40
|
+
GENERAL_SUBATTRS = [:value, :display, :primary, :type].to_set
|
41
|
+
EXPLICIT_SINGLE = {
|
42
|
+
name: [:formatted, :familyname, :givenname, :middlename,
|
43
|
+
:honorificprefix, :honorificsuffix].to_set,
|
44
|
+
meta: [:created, :lastmodified, :location, :version].to_set }
|
45
|
+
EXPLICIT_MULTI = {
|
46
|
+
addresses: [:formatted, :streetaddress, :locality, :region,
|
47
|
+
:postal_code, :country, :primary, :type].to_set,
|
48
|
+
authorizations: [:client_id, :approved, :denied, :exp].to_set }
|
49
|
+
|
50
|
+
# resource class definitions: naming and legal attributes
|
51
|
+
NAME_ATTR = { user: :username, client: :client_id, group: :displayname }
|
52
|
+
COMMON_ATTRS = [:rtype, :externalid, :id, :meta].to_set
|
53
|
+
LEGAL_ATTRS = {
|
54
|
+
user: [*COMMON_ATTRS, :displayname, :username, :nickname,
|
55
|
+
:profileurl, :title, :usertype, :preferredlanguage, :locale,
|
56
|
+
:timezone, :active, :password, :emails, :phonenumbers, :ims, :photos,
|
57
|
+
:entitlements, :roles, :x509certificates, :name, :addresses,
|
58
|
+
:authorizations, :groups].to_set,
|
59
|
+
client: [*COMMON_ATTRS, :client_id, :client_secret, :authorities,
|
60
|
+
:authorized_grant_types, :scope, :auto_approved_scope,
|
61
|
+
:access_token_validity, :refresh_token_validity, :redirect_uri].to_set,
|
62
|
+
group: [*COMMON_ATTRS, :displayname, :members, :owners, :readers].to_set }
|
63
|
+
VISIBLE_ATTRS = {user: Set.new(LEGAL_ATTRS[:user] - HIDDEN_ATTRS),
|
64
|
+
client: Set.new(LEGAL_ATTRS[:client] - HIDDEN_ATTRS),
|
65
|
+
group: Set.new(LEGAL_ATTRS[:group] - HIDDEN_ATTRS)}
|
66
|
+
ATTR_NAMES = LEGAL_ATTRS.each_with_object(Set.new) { |(k, v), s|
|
67
|
+
v.each {|a| s << a.to_s }
|
68
|
+
}
|
69
|
+
SUBATTR_NAMES = GENERAL_SUBATTRS.each_with_object(Set.new) { |sa, s| s << sa.to_s } +
|
70
|
+
[*EXPLICIT_SINGLE, *EXPLICIT_MULTI].each_with_object(Set.new) { |(k, v), s|
|
71
|
+
v.each {|sa| s << sa.to_s }
|
72
|
+
}
|
73
|
+
|
74
|
+
def self.remove_hidden(attrs = nil) attrs - HIDDEN_ATTRS if attrs end
|
75
|
+
def self.searchable_attribute(attr)
|
76
|
+
attr if ATTR_NAMES.include?(attr) && !HIDDEN_ATTRS.include?(attr = attr.to_sym)
|
77
|
+
end
|
78
|
+
|
79
|
+
def remove_attrs(stuff, attrs = HIDDEN_ATTRS)
|
80
|
+
attrs.each { |a| stuff.delete(a.to_s) }
|
81
|
+
stuff
|
82
|
+
end
|
83
|
+
|
84
|
+
def valid_id?(id, rtype)
|
85
|
+
id && (t = @things_by_id[id]) && (rtype.nil? || t[:rtype] == rtype)
|
86
|
+
end
|
87
|
+
|
88
|
+
def ref_by_name(name, rtype) @things_by_name[rtype.to_s + name.downcase] end
|
89
|
+
|
90
|
+
def ref_by_id(id, rtype = nil)
|
91
|
+
(t = @things_by_id[id]) && (rtype.nil? || t[:rtype] == rtype) ? t : nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def valid_complex?(value, subattrs, simple_ok = false)
|
95
|
+
return true if simple_ok && value.is_a?(String)
|
96
|
+
return unless value.is_a?(Hash) && (!simple_ok || value.key?("value"))
|
97
|
+
value.each { |k, v| return unless SUBATTR_NAMES.include?(k) && subattrs.include?(k.to_sym) }
|
98
|
+
end
|
99
|
+
|
100
|
+
def valid_multi?(values, subattrs, simple_ok = false)
|
101
|
+
return unless values.is_a?(Array)
|
102
|
+
values.each { |value| return unless valid_complex?(value, subattrs, simple_ok) }
|
103
|
+
end
|
104
|
+
|
105
|
+
def valid_ids?(value, rtype = nil)
|
106
|
+
return unless value.is_a?(Array)
|
107
|
+
value.each do |ref|
|
108
|
+
return unless ref.is_a?(String) && valid_id?(ref, rtype) ||
|
109
|
+
ref.is_a?(Hash) && valid_id?(ref["value"], rtype)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def enforce_schema(rtype, stuff)
|
114
|
+
stuff.each do |ks, v|
|
115
|
+
unless ATTR_NAMES.include?(ks.to_s) && LEGAL_ATTRS[rtype].include?(k = ks.to_sym)
|
116
|
+
raise SchemaViolation, "illegal #{ks} on #{rtype}"
|
117
|
+
end
|
118
|
+
if READ_ONLY_ATTRS.include?(k)
|
119
|
+
raise SchemaViolation, "attempt to modify read-only attribute #{k} on #{rtype}"
|
120
|
+
end
|
121
|
+
valid_attr = case k
|
122
|
+
when *BOOLEANS then v == !!v
|
123
|
+
when *NUMBERS then v.is_a?(Integer)
|
124
|
+
when *GENERAL_MULTI then valid_multi?(v, GENERAL_SUBATTRS, true)
|
125
|
+
when *GROUPS then valid_ids?(v, :group)
|
126
|
+
when *REFERENCES then valid_ids?(v)
|
127
|
+
when ENUMS[k] then ENUMS[k].include?(v)
|
128
|
+
when *EXPLICIT_SINGLE.keys then valid_complex?(v, EXPLICIT_SINGLE[k])
|
129
|
+
when *EXPLICIT_MULTI.keys then valid_multi?(v, EXPLICIT_MULTI[k])
|
130
|
+
else k.is_a?(String) || k.is_a?(Symbol)
|
131
|
+
end
|
132
|
+
raise SchemaViolation, "#{v} is an invalid #{k}" unless valid_attr
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def input(stuff)
|
137
|
+
thing = Util.hash_keys(stuff.dup, :tosym)
|
138
|
+
REFERENCES.each {|a|
|
139
|
+
next unless thing[a]
|
140
|
+
thing[a] = thing[a].each_with_object(Set.new) { |r, s|
|
141
|
+
s << (r.is_a?(Hash)? r[:value] : r )
|
142
|
+
}
|
143
|
+
}
|
144
|
+
GENERAL_MULTI.each {|a|
|
145
|
+
next unless thing[a]
|
146
|
+
thing[a] = thing[a].each_with_object({}) { |v, o|
|
147
|
+
v = {value: v} unless v.is_a?(Hash)
|
148
|
+
# enforce values are unique by type and value
|
149
|
+
k = URI.encode_www_form(t: [v[:type], v: v[:value]]).downcase
|
150
|
+
o[k] = v
|
151
|
+
}
|
152
|
+
}
|
153
|
+
thing
|
154
|
+
end
|
155
|
+
|
156
|
+
def output(thing, attrs = nil)
|
157
|
+
attrs = thing.keys if attrs.nil? || attrs.empty?
|
158
|
+
attrs.each_with_object({}) {|a, o|
|
159
|
+
next unless thing[a]
|
160
|
+
case a
|
161
|
+
when *REFERENCES then o[a] = thing[a].to_a
|
162
|
+
when *GENERAL_MULTI then o[a] = thing[a].values
|
163
|
+
else o[a] = thing[a]
|
164
|
+
end
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
def add_user_groups(gid, members)
|
169
|
+
members.each {|m| (m[:groups] ||= Set.new) << gid if m = ref_by_id(m, :user)} if members
|
170
|
+
end
|
171
|
+
|
172
|
+
def remove_user_groups(gid, members)
|
173
|
+
members.each {|m| m[:groups].delete(gid) if m = ref_by_id(m, :user) } if members
|
174
|
+
end
|
175
|
+
|
176
|
+
public
|
177
|
+
|
178
|
+
def initialize; @things_by_id, @things_by_name = {}, {} end
|
179
|
+
def name(id, rtype = nil) (t = ref_by_id(id, rtype))? t[NAME_ATTR[t[:rtype]]]: nil end
|
180
|
+
def id(name, rtype) (t = ref_by_name(name, rtype))? t[:id] : nil end
|
181
|
+
|
182
|
+
def add(rtype, stuff)
|
183
|
+
unless stuff.is_a?(Hash) && (name = stuff[NAME_ATTR[rtype].to_s])
|
184
|
+
raise SchemaViolation, "new #{rtype} has no name #{NAME_ATTR[rtype]}"
|
185
|
+
end
|
186
|
+
raise AlreadyExists if @things_by_name.key?(name = rtype.to_s + name.downcase)
|
187
|
+
enforce_schema(rtype, stuff)
|
188
|
+
thing = input(stuff).merge!(rtype: rtype, id: (id = SecureRandom.uuid),
|
189
|
+
meta: { created: Time.now.iso8601, last_modified: Time.now.iso8601, version: 1 })
|
190
|
+
add_user_groups(id, thing[:members])
|
191
|
+
@things_by_id[id] = @things_by_name[name] = thing
|
192
|
+
id
|
193
|
+
end
|
194
|
+
|
195
|
+
def update(id, stuff, match_version = nil, match_type = nil)
|
196
|
+
raise NotFound unless thing = ref_by_id(id, match_type)
|
197
|
+
raise BadVersion if match_version && match_version != thing[:meta][:version]
|
198
|
+
enforce_schema(rtype = thing[:rtype], remove_attrs(stuff, READ_ONLY_ATTRS))
|
199
|
+
new_thing = input(stuff)
|
200
|
+
if newname = new_thing[NAME_ATTR[rtype]]
|
201
|
+
oldname = rtype.to_s + thing[NAME_ATTR[rtype]].downcase
|
202
|
+
unless (newname = rtype.to_s + newname.downcase) == oldname
|
203
|
+
raise AlreadyExists if @things_by_name.key?(newname)
|
204
|
+
@things_by_name.delete(oldname)
|
205
|
+
@things_by_name[newname] = thing
|
206
|
+
end
|
207
|
+
end
|
208
|
+
if new_thing[:members] || thing[:members]
|
209
|
+
old_members = thing[:members] || Set.new
|
210
|
+
new_members = new_thing[:members] || Set.new
|
211
|
+
remove_user_groups(id, old_members - new_members)
|
212
|
+
add_user_groups(id, new_members - old_members)
|
213
|
+
end
|
214
|
+
READ_ONLY_ATTRS.each { |a| new_thing[a] = thing[a] if thing[a] }
|
215
|
+
HIDDEN_ATTRS.each { |a| new_thing[a] = thing[a] if thing[a] }
|
216
|
+
thing.replace new_thing
|
217
|
+
thing[:meta][:version] += 1
|
218
|
+
thing[:meta][:lastmodified] == Time.now.iso8601
|
219
|
+
id
|
220
|
+
end
|
221
|
+
|
222
|
+
def add_member(gid, member)
|
223
|
+
return unless g = ref_by_id(gid, :group)
|
224
|
+
(g[:members] ||= Set.new) << member
|
225
|
+
add_user_groups(gid, Set[member])
|
226
|
+
end
|
227
|
+
|
228
|
+
def set_hidden_attr(id, attr, value)
|
229
|
+
raise NotFound unless thing = ref_by_id(id)
|
230
|
+
raise ArgumentError unless HIDDEN_ATTRS.include?(attr)
|
231
|
+
thing[attr] = value
|
232
|
+
end
|
233
|
+
|
234
|
+
def remove(id, rtype = nil)
|
235
|
+
return unless thing = ref_by_id(id, rtype)
|
236
|
+
rtype = thing[:rtype]
|
237
|
+
remove_user_groups(id, thing[:members])
|
238
|
+
@things_by_id.delete(id)
|
239
|
+
thing = @things_by_name.delete(rtype.to_s + thing[NAME_ATTR[rtype]].downcase)
|
240
|
+
remove_attrs(output(thing))
|
241
|
+
end
|
242
|
+
|
243
|
+
def get(id, rtype = nil, *attrs)
|
244
|
+
return unless thing = ref_by_id(id, rtype)
|
245
|
+
output(thing, attrs)
|
246
|
+
end
|
247
|
+
|
248
|
+
def get_by_name(name, rtype, *attrs)
|
249
|
+
return unless thing = ref_by_name(name, rtype)
|
250
|
+
output(thing, attrs)
|
251
|
+
end
|
252
|
+
|
253
|
+
def find(rtype, start = 0, count = nil, filter_string = nil, attrs = nil)
|
254
|
+
filter, total = ScimFilter.new(filter_string), 0
|
255
|
+
objs = @things_by_id.each_with_object([]) { |(k, v), o|
|
256
|
+
next unless rtype == v[:rtype] && filter.match?(v)
|
257
|
+
o << output(v, attrs) if total >= start && (count.nil? || o.length < count)
|
258
|
+
total += 1
|
259
|
+
}
|
260
|
+
[objs, total]
|
261
|
+
end
|
262
|
+
|
263
|
+
end
|
264
|
+
|
265
|
+
class ScimFilter
|
266
|
+
|
267
|
+
private
|
268
|
+
|
269
|
+
def eat_json_string
|
270
|
+
raise BadFilter unless @input.skip(/\s*"/)
|
271
|
+
str = ""
|
272
|
+
while true
|
273
|
+
case
|
274
|
+
when @input.scan(/[^\\"]+/); str << @input.matched
|
275
|
+
when @input.scan(%r{\\["\\/]}); str << @input.matched[-1]
|
276
|
+
when @input.scan(/\\[bfnrt]/); str << eval(%Q{"#{@input.matched}"})
|
277
|
+
when @input.scan(/\\u[0-9a-fA-F]{4}/); str << [Integer("0x#{@input.matched[2..-1]}")].pack("U")
|
278
|
+
else break
|
279
|
+
end
|
280
|
+
end
|
281
|
+
raise BadFilter unless @input.skip(/"\s*/)
|
282
|
+
str
|
283
|
+
end
|
284
|
+
|
285
|
+
def eat_word(*words)
|
286
|
+
@input.skip(/\s*/)
|
287
|
+
return unless s = @input.scan(/(\S+)\s*/)
|
288
|
+
w = @input[1].downcase
|
289
|
+
return w if words.empty? || words.include?(w)
|
290
|
+
@input.unscan
|
291
|
+
false
|
292
|
+
end
|
293
|
+
|
294
|
+
def eat_expr
|
295
|
+
if @input.skip(/\s*\(\s*/)
|
296
|
+
phrase = eat_phrase
|
297
|
+
raise BadFilter unless @input.skip(/\s*\)\s*/)
|
298
|
+
return phrase
|
299
|
+
end
|
300
|
+
raise BadFilter unless (attr = eat_word) &&
|
301
|
+
(op = eat_word("eq", "co", "sw", "pr", "gt", "ge", "lt", "le")) &&
|
302
|
+
(op == "pr" || value = eat_json_string)
|
303
|
+
(attr_sym = StubScim.searchable_attribute(attr)) ?
|
304
|
+
[:item, attr_sym, op, value] : [:undefined, attr, op, value]
|
305
|
+
end
|
306
|
+
|
307
|
+
# AND level
|
308
|
+
def eat_subphrase
|
309
|
+
phrase = [:and, eat_expr]
|
310
|
+
while eat_word("and"); phrase << eat_expr end
|
311
|
+
phrase.length == 2 ? phrase[1] : phrase
|
312
|
+
end
|
313
|
+
|
314
|
+
# OR level
|
315
|
+
def eat_phrase
|
316
|
+
phrase = [:or, eat_subphrase]
|
317
|
+
while eat_word("or"); phrase << eat_subphrase end
|
318
|
+
phrase.length == 2 ? phrase[1] : phrase
|
319
|
+
end
|
320
|
+
|
321
|
+
def eval_expr(entry, attr, op, value)
|
322
|
+
return false unless val = entry[attr]
|
323
|
+
return true if op == "pr"
|
324
|
+
case attr
|
325
|
+
when *StubScim::REFERENCES
|
326
|
+
return nil unless op == "eq"
|
327
|
+
val.each {|v| return true if v.casecmp(value) == 0 }
|
328
|
+
false
|
329
|
+
when *StubScim::GENERAL_MULTI
|
330
|
+
return nil unless op == "eq"
|
331
|
+
val.each {|k, v| return true if v.casecmp(value) == 0 }
|
332
|
+
false
|
333
|
+
else
|
334
|
+
case op
|
335
|
+
when "eq"; val.casecmp(value) == 0
|
336
|
+
when "sw"; val =~ /^#{Regexp.escape(value)}/i
|
337
|
+
when "co"; val =~ /#{Regexp.escape(value)}/i
|
338
|
+
when "gt"; val.casecmp(value) > 0
|
339
|
+
when "ge"; val.casecmp(value) >= 0
|
340
|
+
when "lt"; val.casecmp(value) < 0
|
341
|
+
when "le"; val.casecmp(value) <= 0
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
def eval(entry, filtr)
|
347
|
+
undefd = 0
|
348
|
+
case filtr[0]
|
349
|
+
when :undefined ; nil
|
350
|
+
when :item ; eval_expr(entry, filtr[1], filtr[2], filtr[3])
|
351
|
+
when :or
|
352
|
+
filtr[1..-1].each { |f|
|
353
|
+
return true if (res = eval(entry, f)) == true
|
354
|
+
undefd += 1 if res.nil?
|
355
|
+
}
|
356
|
+
filtr.length == undefd + 1 ? nil: false
|
357
|
+
when :and
|
358
|
+
filtr[1..-1].each { |f|
|
359
|
+
return false if (res = eval(entry, f)) == false
|
360
|
+
undefd += 1 if res.nil?
|
361
|
+
}
|
362
|
+
filtr.length == undefd + 1 ? nil: true
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
public
|
367
|
+
|
368
|
+
def initialize(filter_string)
|
369
|
+
if filter_string.nil?
|
370
|
+
@filter = true
|
371
|
+
else
|
372
|
+
@input = StringScanner.new(filter_string)
|
373
|
+
@filter = eat_phrase
|
374
|
+
raise BadFilter unless @input.eos?
|
375
|
+
end
|
376
|
+
self
|
377
|
+
rescue BadFilter => b
|
378
|
+
raise BadFilter, "invalid filter expression at offset #{@input.pos}: #{@input.string}"
|
379
|
+
end
|
380
|
+
|
381
|
+
def match?(entry)
|
382
|
+
@filter == true || eval(entry, @filter)
|
383
|
+
end
|
384
|
+
|
385
|
+
end
|
386
|
+
|
387
|
+
end
|
data/lib/stub/server.rb
ADDED
@@ -0,0 +1,310 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'eventmachine'
|
15
|
+
require 'date'
|
16
|
+
require 'logger'
|
17
|
+
require 'pp'
|
18
|
+
require 'erb'
|
19
|
+
require 'multi_json'
|
20
|
+
|
21
|
+
module Stub
|
22
|
+
|
23
|
+
class StubError < RuntimeError; end
|
24
|
+
class BadHeader < StubError; end
|
25
|
+
|
26
|
+
#------------------------------------------------------------------------------
|
27
|
+
class Request
|
28
|
+
|
29
|
+
attr_reader :headers, :body, :path, :method
|
30
|
+
def initialize; @state, @prelude = :init, "" end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def bslice(str, range)
|
35
|
+
# byteslice is available in ruby 1.9.3
|
36
|
+
str.respond_to?(:byteslice) ? str.byteslice(range) : str.slice(range)
|
37
|
+
end
|
38
|
+
|
39
|
+
def add_lines(str)
|
40
|
+
return @body << str if @state == :body
|
41
|
+
processed = 0
|
42
|
+
str.each_line("\r\n") do |ln|
|
43
|
+
processed += ln.bytesize
|
44
|
+
unless ln.chomp!("\r\n")
|
45
|
+
raise BadHeader unless ln.ascii_only?
|
46
|
+
return @prelude = ln # must be partial header at end of str
|
47
|
+
end
|
48
|
+
if @state == :init
|
49
|
+
start = ln.split(/\s+/)
|
50
|
+
@method, @path, @headers, @body = start[0].downcase, start[1], {}, ""
|
51
|
+
raise BadHeader unless @method.ascii_only? && @path.ascii_only?
|
52
|
+
@state = :headers
|
53
|
+
elsif ln.empty?
|
54
|
+
@state, @content_length = :body, headers["content-length"].to_i
|
55
|
+
return @body << bslice(str, processed..-1)
|
56
|
+
else
|
57
|
+
raise BadHeader unless ln.ascii_only?
|
58
|
+
key, sep, val = ln.partition(/:\s+/)
|
59
|
+
@headers[key.downcase] = val
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
public
|
65
|
+
|
66
|
+
# adds data to the request, returns true if request is complete
|
67
|
+
def completed?(str)
|
68
|
+
str, @prelude = @prelude + str, "" unless @prelude.empty?
|
69
|
+
add_lines(str)
|
70
|
+
return unless @state == :body && @body.bytesize >= @content_length
|
71
|
+
@prelude = bslice(@body, @content_length..-1)
|
72
|
+
@body = bslice(@body, 0..@content_length)
|
73
|
+
@state = :init
|
74
|
+
end
|
75
|
+
|
76
|
+
def cookies
|
77
|
+
return {} unless chdr = @headers["cookie"]
|
78
|
+
chdr.strip.split(/\s*;\s*/).each_with_object({}) do |pair, o|
|
79
|
+
k, v = pair.split(/\s*=\s*/)
|
80
|
+
o[k.downcase] = v
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
#------------------------------------------------------------------------------
|
87
|
+
class Reply
|
88
|
+
attr_accessor :status, :headers, :body
|
89
|
+
def initialize(status = 200) @status, @headers, @cookies, @body = status, {}, [], "" end
|
90
|
+
def to_s
|
91
|
+
reply = "HTTP/1.1 #{@status} OK\r\n"
|
92
|
+
headers["server"] = "stub server"
|
93
|
+
headers["date"] = DateTime.now.httpdate
|
94
|
+
headers["content-length"] = body.bytesize
|
95
|
+
headers.each { |k, v| reply << "#{k}: #{v}\r\n" }
|
96
|
+
@cookies.each { |c| reply << "Set-Cookie: #{c}\r\n" }
|
97
|
+
reply << "\r\n" << body
|
98
|
+
end
|
99
|
+
def json(status = nil, info)
|
100
|
+
info = {message: info} unless info.respond_to? :each
|
101
|
+
@status = status if status
|
102
|
+
headers["content-type"] = "application/json"
|
103
|
+
@body = MultiJson.dump(info)
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
def text(status = nil, info)
|
107
|
+
@status = status if status
|
108
|
+
headers["content-type"] = "text/plain"
|
109
|
+
@body = info.pretty_inspect
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
def html(status = nil, info)
|
113
|
+
@status = status if status
|
114
|
+
headers["content-type"] = "text/html"
|
115
|
+
info = ERB::Util.html_escape(info.pretty_inspect) unless info.is_a?(String)
|
116
|
+
@body = "<html><body>#{info}</body></html>"
|
117
|
+
nil
|
118
|
+
end
|
119
|
+
def set_cookie(name, value, options = {})
|
120
|
+
@cookies << options.each_with_object("#{name}=#{value}") { |(k, v), o|
|
121
|
+
o << (v.nil? ? "; #{k}" : "; #{k}=#{v}")
|
122
|
+
}
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
#------------------------------------------------------------------------------
|
127
|
+
# request handler logic -- server is initialized with a class derived from this.
|
128
|
+
# there will be one instance of this object per connection.
|
129
|
+
class Base
|
130
|
+
attr_accessor :request, :reply, :match, :server
|
131
|
+
|
132
|
+
def self.route(http_methods, matcher, filters = {}, &handler)
|
133
|
+
fail unless !EM.reactor_running? || EM.reactor_thread?
|
134
|
+
matcher = Regexp.new("^#{Regexp.escape(matcher.to_s)}$") unless matcher.is_a?(Regexp)
|
135
|
+
filters = filters.each_with_object({}) { |(k, v), o|
|
136
|
+
o[k.downcase] = v.is_a?(Regexp) ? v : Regexp.new("^#{Regexp.escape(v.to_s)}$")
|
137
|
+
}
|
138
|
+
@routes ||= {}
|
139
|
+
@route_number = @route_number.to_i + 1
|
140
|
+
route_name = "route_#{@route_number}".to_sym
|
141
|
+
define_method(route_name, handler)
|
142
|
+
[*http_methods].each do |m|
|
143
|
+
m = m.to_s.downcase
|
144
|
+
@routes[m] ||= []
|
145
|
+
i = @routes[m].index { |r| r[0].to_s.length < matcher.to_s.length }
|
146
|
+
unless i && @routes[m][i][0] == matcher
|
147
|
+
@routes[m].insert(i || -1, [matcher, filters, route_name])
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def self.find_route(request)
|
153
|
+
fail unless EM.reactor_thread?
|
154
|
+
if @routes && (rary = @routes[request.method])
|
155
|
+
rary.each { |r; m|
|
156
|
+
next unless (m = r[0].match(request.path))
|
157
|
+
r[1].each { |k, v|
|
158
|
+
next if v.match(request.headers[k])
|
159
|
+
return reply_in_kind(400, "header '#{k}: #{request.headers[k]}' is not accepted")
|
160
|
+
}
|
161
|
+
return [m, r[2]]
|
162
|
+
}
|
163
|
+
end
|
164
|
+
[nil, :default_route]
|
165
|
+
end
|
166
|
+
|
167
|
+
def initialize(server)
|
168
|
+
@server, @request, @reply, @match = server, Request.new, Reply.new, nil
|
169
|
+
end
|
170
|
+
|
171
|
+
def process
|
172
|
+
@reply = Reply.new
|
173
|
+
@match, handler = self.class.find_route(request)
|
174
|
+
server.logger.debug "processing request to path #{request.path} for route #{@match ? @match.regexp : 'default'}"
|
175
|
+
send handler
|
176
|
+
reply.headers['connection'] ||= request.headers['connection'] if request.headers['connection']
|
177
|
+
server.logger.debug "replying to path #{request.path} with #{reply.body.length} bytes of #{reply.headers['content-type']}"
|
178
|
+
#server.logger.debug "full reply is: #{reply.body.inspect}"
|
179
|
+
rescue Exception => e
|
180
|
+
server.logger.debug "exception from route handler: #{e.message}"
|
181
|
+
server.trace { e.backtrace }
|
182
|
+
reply_in_kind 500, e
|
183
|
+
end
|
184
|
+
|
185
|
+
def reply_in_kind(status = nil, info)
|
186
|
+
case request.headers['accept']
|
187
|
+
when /application\/json/ then reply.json(status, info)
|
188
|
+
when /text\/html/ then reply.html(status, info)
|
189
|
+
else reply.text(status, info)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def default_route
|
194
|
+
reply_in_kind(404, error: "path not handled")
|
195
|
+
end
|
196
|
+
|
197
|
+
end
|
198
|
+
|
199
|
+
#------------------------------------------------------------------------------
|
200
|
+
module Connection
|
201
|
+
attr_accessor :req_handler
|
202
|
+
def unbind; req_handler.server.delete_connection(self) end
|
203
|
+
|
204
|
+
def receive_data(data)
|
205
|
+
#req_handler.server.logger.debug "got #{data.bytesize} bytes: #{data.inspect}"
|
206
|
+
return unless req_handler.request.completed? data
|
207
|
+
req_handler.process
|
208
|
+
send_data req_handler.reply.to_s
|
209
|
+
if req_handler.reply.headers['connection'] =~ /^close$/i || req_handler.server.status != :running
|
210
|
+
close_connection_after_writing
|
211
|
+
end
|
212
|
+
rescue Exception => e
|
213
|
+
req_handler.server.logger.debug "exception from receive_data: #{e.message}"
|
214
|
+
req_handler.server.trace { e.backtrace }
|
215
|
+
close_connection
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
#--------------------------------------------------------------------------
|
220
|
+
class Server
|
221
|
+
attr_reader :host, :port, :status, :logger
|
222
|
+
attr_accessor :info
|
223
|
+
def url; "http://#{@host}:#{@port}" end
|
224
|
+
def trace(msg = nil, &blk); logger.trace(msg, &blk) if logger.respond_to?(:trace) end
|
225
|
+
|
226
|
+
def initialize(req_handler, logger = Logger.new($stdout), info = nil)
|
227
|
+
@req_handler, @logger, @info = req_handler, logger, info
|
228
|
+
@connections, @status, @sig, @em_thread = [], :stopped, nil, nil
|
229
|
+
end
|
230
|
+
|
231
|
+
def start(hostname = "localhost", port = nil)
|
232
|
+
raise ArgumentError, "attempt to start a server that's already running" unless @status == :stopped
|
233
|
+
@host = hostname
|
234
|
+
logger.debug "starting #{self.class} server #{@host}"
|
235
|
+
EM.schedule do
|
236
|
+
@sig = EM.start_server(@host, port || 0, Connection) { |c| initialize_connection(c) }
|
237
|
+
@port = Socket.unpack_sockaddr_in(EM.get_sockname(@sig))[0]
|
238
|
+
logger.debug "#{self.class} server started at #{url}, signature #{@sig}"
|
239
|
+
end
|
240
|
+
@status = :running
|
241
|
+
self
|
242
|
+
end
|
243
|
+
|
244
|
+
def run_on_thread(hostname = "localhost", port = 0)
|
245
|
+
raise ArgumentError, "can't run on thread, EventMachine already running" if EM.reactor_running?
|
246
|
+
logger.debug { "starting eventmachine on thread" }
|
247
|
+
cthred = Thread.current
|
248
|
+
@em_thread = Thread.new do
|
249
|
+
begin
|
250
|
+
EM.run { start(hostname, port); cthred.run }
|
251
|
+
logger.debug "server thread done"
|
252
|
+
rescue Exception => e
|
253
|
+
logger.debug { "unhandled exception on stub server thread: #{e.message}" }
|
254
|
+
trace { e.backtrace }
|
255
|
+
raise
|
256
|
+
end
|
257
|
+
end
|
258
|
+
Thread.stop
|
259
|
+
logger.debug "running on thread"
|
260
|
+
self
|
261
|
+
end
|
262
|
+
|
263
|
+
def run(hostname = "localhost", port = 0)
|
264
|
+
raise ArgumentError, "can't run, EventMachine already running" if EM.reactor_running?
|
265
|
+
@em_thread = Thread.current
|
266
|
+
EM.run { start(hostname, port) }
|
267
|
+
logger.debug "server and event machine done"
|
268
|
+
end
|
269
|
+
|
270
|
+
# if on reactor thread, start shutting down but return if connections still
|
271
|
+
# in process, and let them disconnect when complete -- server is not really
|
272
|
+
# done until it's status is stopped.
|
273
|
+
# if not on reactor thread, wait until everything's cleaned up and stopped
|
274
|
+
def stop
|
275
|
+
logger.debug "stopping server"
|
276
|
+
@status = :stopping
|
277
|
+
EM.stop_server @sig
|
278
|
+
done if @connections.empty?
|
279
|
+
sleep 0.1 while @status != :stopped unless EM.reactor_thread?
|
280
|
+
end
|
281
|
+
|
282
|
+
def delete_connection(conn)
|
283
|
+
logger.debug "deleting connection"
|
284
|
+
fail unless EM.reactor_thread?
|
285
|
+
@connections.delete(conn)
|
286
|
+
done if @status != :running && @connections.empty?
|
287
|
+
end
|
288
|
+
|
289
|
+
private
|
290
|
+
|
291
|
+
def done
|
292
|
+
fail unless @connections.empty?
|
293
|
+
EM.stop if @em_thread && EM.reactor_running?
|
294
|
+
@connections, @status, @sig, @em_thread = [], :stopped, nil, nil
|
295
|
+
sleep 0.1 unless EM.reactor_thread? # give EM a chance to stop
|
296
|
+
logger.debug EM.reactor_running? ?
|
297
|
+
"server done but EM still running" : "server really done"
|
298
|
+
end
|
299
|
+
|
300
|
+
def initialize_connection(conn)
|
301
|
+
logger.debug "starting connection"
|
302
|
+
fail unless EM.reactor_thread?
|
303
|
+
@connections << conn
|
304
|
+
conn.req_handler = @req_handler.new(self)
|
305
|
+
conn.comm_inactivity_timeout = 30
|
306
|
+
end
|
307
|
+
|
308
|
+
end
|
309
|
+
|
310
|
+
end
|