xplenty-api 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,53 @@
1
+ module Xplenty
2
+ class API
3
+ # GET <accountID>/api/jobs/<jobID>
4
+ def get_job_info(jobID)
5
+ request(
6
+ :expects => 200,
7
+ :method => :get,
8
+ :path => "/#{account_id}/api/jobs/#{jobID}"
9
+ )
10
+ end
11
+
12
+ # GET <accountID>/api/jobs
13
+ def jobs(options={})
14
+ request(
15
+ :expects => 200,
16
+ :method => :get,
17
+ :path => "/#{account_id}/api/jobs",
18
+ :query => options
19
+ )
20
+ end
21
+
22
+ # POST /<accountID>/api/jobs
23
+ def run_job(clusterID, packageID, args = {})
24
+ query = args.inject({}) {|v, kv|
25
+ v.merge(
26
+ {
27
+ "job[variables][#{kv.first}]" => kv.last
28
+ })
29
+ }
30
+ query = query.merge(
31
+ {
32
+ 'job[cluster_id]' => clusterID,
33
+ 'job[package_id]' => packageID
34
+ })
35
+
36
+ request(
37
+ :expects => 201,
38
+ :method => :post,
39
+ :path => "/#{account_id}/api/jobs",
40
+ :query => query
41
+ )
42
+ end
43
+
44
+ # DELETE /<accountID>/api/jobs/<jobID>
45
+ def terminate_job(jobID)
46
+ request(
47
+ :expects => 200,
48
+ :method => :delete,
49
+ :path => "/#{account_id}/api/jobs/#{jobID}"
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,87 @@
1
+ require 'xplenty/api/mock/clusters'
2
+ require 'xplenty/api/mock/jobs'
3
+
4
+ module Xplenty
5
+ class API
6
+ module Mock
7
+
8
+ CLUSTER_NOT_FOUND = { :body => 'Cluster not found.', :status => 404 }
9
+ JOB_NOT_FOUND = { :body => 'Job not found.', :status => 404 }
10
+
11
+ @mock_data = Hash.new do |hash, key|
12
+ hash[key] = {
13
+ :clusters => [],
14
+ :jobs => [],
15
+ :watchers => {},
16
+ }
17
+ end
18
+
19
+ def self.get_mock_cluster(mock_data, cluster_id)
20
+ @clusters ||= begin
21
+ data = File.read("#{File.dirname(__FILE__)}/mock/cache/clusters.json")
22
+ Xplenty::API::OkJson.decode(data)
23
+ end
24
+ mock_data[:clusters] = @clusters
25
+
26
+ mock_data[:clusters].detect {|cluster_data| cluster_data['id'].to_s == cluster_id.to_s}
27
+ end
28
+
29
+ def self.get_mock_job(mock_data, job_id)
30
+ @jobs ||= begin
31
+ data = File.read("#{File.dirname(__FILE__)}/mock/cache/jobs.json")
32
+ Xplenty::API::OkJson.decode(data)
33
+ end
34
+ mock_data[:jobs] = @jobs
35
+
36
+ mock_data[:jobs].detect {|job_data| job_data['id'].to_s == job_id.to_s}
37
+ end
38
+
39
+ def self.parse_stub_params(params)
40
+ mock_data = nil
41
+
42
+ if params[:headers].has_key?('Authorization')
43
+ api_key = Base64.decode64(params[:headers]['Authorization']).split(':').last
44
+
45
+ parsed = params.dup
46
+ begin # try to JSON decode
47
+ parsed[:body] &&= Xplenty::API::OkJson.decode(parsed[:body])
48
+ rescue # else leave as is
49
+ end
50
+ mock_data = @mock_data[api_key]
51
+ end
52
+
53
+ [parsed, mock_data]
54
+ end
55
+
56
+ def self.with_mock_cluster(mock_data, cluster_id, &block)
57
+ if cluster_data = get_mock_cluster(mock_data, cluster_id)
58
+ yield(cluster_data)
59
+ else
60
+ CLUSTER_NOT_FOUND
61
+ end
62
+ end
63
+
64
+ def self.with_mock_job(mock_data, job_id, &block)
65
+ if job_data = get_mock_job(mock_data, job_id)
66
+ yield(job_data)
67
+ else
68
+ JOB_NOT_FOUND
69
+ end
70
+ end
71
+
72
+ def self.with_mock_clusters(mock_data, &block)
73
+ @clusters ||= begin
74
+ data = File.read("#{File.dirname(__FILE__)}/mock/cache/clusters.json")
75
+ Xplenty::API::OkJson.decode(data)
76
+ end
77
+ mock_data[:clusters] = @clusters
78
+ yield(mock_data)
79
+ end
80
+
81
+ def self.timestamp
82
+ Time.now.strftime("%G/%m/%d %H:%M:%S %z")
83
+ end
84
+
85
+ end
86
+ end
87
+ end
@@ -0,0 +1 @@
1
+ [{"id":1746,"name":"cyan-wave-764","description":null,"status":"available","owner_id":69,"plan_id":null,"nodes":1,"type":"sandbox","created_at":"2013-05-09T10:35:00Z","updated_at":"2013-05-09T10:41:18Z","available_since":"2013-05-09T10:41:18Z","terminated_at":null,"running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1746"},{"id":1745,"name":"red-sea-1285","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":1,"type":"sandbox","created_at":"2013-05-09T10:27:22Z","updated_at":"2013-05-09T10:34:23Z","available_since":"2013-05-09T10:33:47Z","terminated_at":"2013-05-09T10:34:23Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1745"},{"id":1744,"name":"ruby-city-1294","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T10:26:40Z","updated_at":"2013-05-09T10:28:47Z","available_since":null,"terminated_at":"2013-05-09T10:28:47Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1744"},{"id":1742,"name":"indigo-hill-188","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":1,"type":"sandbox","created_at":"2013-05-09T09:59:07Z","updated_at":"2013-05-09T10:07:38Z","available_since":"2013-05-09T10:06:52Z","terminated_at":"2013-05-09T10:07:38Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1742"},{"id":1741,"name":"cyan-tuya-392","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T09:56:06Z","updated_at":"2013-05-09T10:17:20Z","available_since":null,"terminated_at":"2013-05-09T10:17:20Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1741"},{"id":1740,"name":"cyan-waterpark-1462","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T09:55:05Z","updated_at":"2013-05-09T10:24:09Z","available_since":null,"terminated_at":"2013-05-09T10:24:09Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1740"},{"id":1739,"name":"dry-beach-1795","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T09:54:42Z","updated_at":"2013-05-09T10:24:25Z","available_since":null,"terminated_at":"2013-05-09T10:24:25Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1739"},{"id":1738,"name":"blue-pool-875","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T09:54:04Z","updated_at":"2013-05-09T10:24:19Z","available_since":null,"terminated_at":"2013-05-09T10:24:19Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1738"},{"id":1737,"name":"pink-waterpark-562","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":0,"type":"sandbox","created_at":"2013-05-09T09:53:36Z","updated_at":"2013-05-09T10:24:08Z","available_since":null,"terminated_at":"2013-05-09T10:24:08Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1737"},{"id":1711,"name":"test_cluster","description":"NA","status":"terminated","owner_id":69,"plan_id":null,"nodes":4,"type":"sandbox","created_at":"2013-05-07T03:49:59Z","updated_at":"2013-05-07T04:00:41Z","available_since":"2013-05-07T03:59:57Z","terminated_at":"2013-05-07T04:00:41Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1711"},{"id":1709,"name":"orange-tree-1115","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":1,"type":"sandbox","created_at":"2013-05-06T20:41:18Z","updated_at":"2013-05-07T03:47:04Z","available_since":"2013-05-06T20:48:23Z","terminated_at":"2013-05-07T03:47:03Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1709"},{"id":1707,"name":"icy-coast-658","description":null,"status":"terminated","owner_id":69,"plan_id":null,"nodes":1,"type":"sandbox","created_at":"2013-05-06T17:00:13Z","updated_at":"2013-05-06T20:40:54Z","available_since":"2013-05-06T17:09:45Z","terminated_at":"2013-05-06T20:40:54Z","running_jobs_count":0,"url":"https://api.xplenty.com/xmpolaris/api/clusters/1707"}]
@@ -0,0 +1 @@
1
+ [{"id":9038,"status":"stopped","variables":{},"owner_id":69,"progress":0.0,"outputs_count":0,"started_at":null,"completed_at":"2013-05-09T11:21:05Z","failed_at":null,"created_at":"2013-05-09T11:21:03Z","updated_at":"2013-05-09T11:21:06Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9038","runtime_in_seconds":0},{"id":9037,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:20:31Z","completed_at":"2013-05-09T11:21:05Z","failed_at":null,"created_at":"2013-05-09T11:20:25Z","updated_at":"2013-05-09T11:21:05Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9037","runtime_in_seconds":34},{"id":9036,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:20:15Z","completed_at":"2013-05-09T11:20:48Z","failed_at":null,"created_at":"2013-05-09T11:20:10Z","updated_at":"2013-05-09T11:20:49Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9036","runtime_in_seconds":34},{"id":9033,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:10:36Z","completed_at":"2013-05-09T11:11:09Z","failed_at":null,"created_at":"2013-05-09T11:10:34Z","updated_at":"2013-05-09T11:11:09Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9033","runtime_in_seconds":34},{"id":9032,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:10:21Z","completed_at":"2013-05-09T11:10:56Z","failed_at":null,"created_at":"2013-05-09T11:10:17Z","updated_at":"2013-05-09T11:10:56Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9032","runtime_in_seconds":35},{"id":9031,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:09:55Z","completed_at":"2013-05-09T11:10:29Z","failed_at":null,"created_at":"2013-05-09T11:09:50Z","updated_at":"2013-05-09T11:10:29Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9031","runtime_in_seconds":34},{"id":9030,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-09T11:09:27Z","completed_at":"2013-05-09T11:10:05Z","failed_at":null,"created_at":"2013-05-09T11:09:20Z","updated_at":"2013-05-09T11:10:05Z","cluster_id":1746,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/9030","runtime_in_seconds":38},{"id":9025,"status":"failed","variables":{},"owner_id":69,"progress":0.0,"outputs_count":0,"started_at":null,"completed_at":"2013-05-09T09:58:37Z","failed_at":"2013-05-09T09:58:37Z","created_at":"2013-05-09T09:58:36Z","updated_at":"2013-05-09T09:58:41Z","cluster_id":1741,"package_id":682,"errors":"Specified cluster is not available and is in erroneous state.","url":"https://api.xplenty.com/xmpolaris/api/jobs/9025","runtime_in_seconds":0},{"id":8885,"status":"completed","variables":{},"owner_id":69,"progress":1.0,"outputs_count":0,"started_at":"2013-05-06T20:49:38Z","completed_at":"2013-05-06T20:50:14Z","failed_at":null,"created_at":"2013-05-06T20:49:31Z","updated_at":"2013-05-06T20:50:14Z","cluster_id":1709,"package_id":682,"errors":null,"url":"https://api.xplenty.com/xmpolaris/api/jobs/8885","runtime_in_seconds":36}]
@@ -0,0 +1,86 @@
1
+ module Xplenty
2
+ class API
3
+ module Mock
4
+
5
+ # stub POST /:account_id/api/clusters
6
+ Excon.stub(:expects => 201, :method => :post, :path => %r{^/\w+/api/clusters$} ) do |params|
7
+ request_params, mock_data = parse_stub_params(params)
8
+
9
+ new_id = rand(99999)
10
+
11
+ if get_mock_cluster(mock_data, new_id)
12
+ {
13
+ :body => {:error => 'Already exists'},
14
+ :status => 500
15
+ }
16
+ else
17
+ cluster_data = {
18
+ 'id' => new_id,
19
+ 'name' => "New Cluster",
20
+ 'description' => "New Cluster Description",
21
+ 'status' => "pending",
22
+ 'owner_id' => 27,
23
+ 'plan_id' => 1,
24
+ 'nodes' => 1,
25
+ 'type' => "sandbox",
26
+ 'created_at' => timestamp,
27
+ 'updated_at' => timestamp,
28
+ 'available_since' => nil,
29
+ 'terminated_at' => Time.now.to_s,
30
+ 'running_jobs_count' => 0,
31
+ 'url' => "https://api.xplenty.com/xplenation/api/clusters/167"
32
+ }
33
+ mock_data[:clusters] << cluster_data
34
+ {
35
+ :body => Xplenty::API::OkJson.encode(cluster_data),
36
+ :status => 201
37
+ }
38
+ end
39
+ end
40
+
41
+ # stub GET /:account_id/api/clusters
42
+ Excon.stub(:expects => 200, :method => :get, :path => %r{^/\w+/api/clusters$} ) do |params|
43
+ {
44
+ :body => File.read("#{File.dirname(__FILE__)}/cache/clusters.json"),
45
+ :status => 200
46
+ }
47
+ end
48
+
49
+ # stub DELETE /:account_id/api/clusters/:cluster_id
50
+ Excon.stub(:expects => 200, :method => :delete, :path => %r{^/\w+/api/clusters/([^/]+)$} ) do |params|
51
+ request_params, mock_data = parse_stub_params(params)
52
+ cluster_id, _ = request_params[:captures][:path]
53
+
54
+ cluster = get_mock_cluster(mock_data, cluster_id)
55
+
56
+ if cluster
57
+ # mock_data[:clusters].delete cluster
58
+ {
59
+ :body => Xplenty::API::OkJson.encode(cluster),
60
+ :status => 200
61
+ }
62
+ else
63
+ CLUSTER_NOT_FOUND
64
+ end
65
+ end
66
+
67
+ # stub GET /:account_id/api/clusters/:cluster_id
68
+ Excon.stub(:expects => 200, :method => :get, :path => %r{^/\w+/api/clusters/([^/]+)$} ) do |params|
69
+ request_params, mock_data = parse_stub_params(params)
70
+ cluster_id, _ = request_params[:captures][:path]
71
+
72
+ cluster = get_mock_cluster(mock_data, cluster_id)
73
+
74
+ if cluster
75
+ {
76
+ :body => Xplenty::API::OkJson.encode(cluster),
77
+ :status => 200
78
+ }
79
+ else
80
+ CLUSTER_NOT_FOUND
81
+ end
82
+ end
83
+
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,86 @@
1
+ module Xplenty
2
+ class API
3
+ module Mock
4
+
5
+ # stub POST /:account_id/api/jobs
6
+ Excon.stub(:expects => 201, :method => :post, :path => %r{^/\w+/api/jobs} ) do |params|
7
+ request_params, mock_data = parse_stub_params(params)
8
+
9
+ params = request_params[:query]
10
+
11
+ new_id = rand(99999)
12
+ if get_mock_job(mock_data, new_id)
13
+ {
14
+ :body => {:error => 'Already exists'},
15
+ :status => 500
16
+ }
17
+ else
18
+ job_data = {
19
+ 'id' => new_id,
20
+ 'status' => "pending",
21
+ 'variables' => "New Cluster Description",
22
+ 'owner_id' => "1",
23
+ 'progress' => 0,
24
+ 'outputs_count' => 0,
25
+ 'started_at' => timestamp,
26
+ 'created_at' => timestamp,
27
+ 'updated_at' => timestamp,
28
+ 'failed_at' => nil,
29
+ 'cluster_id' => params['job[cluster_id]'],
30
+ 'package_id' => params['job[package_id]'],
31
+ "errors" => nil,
32
+ "url" => "https://api.xplenty.com/xplenation/api/jobs/#{new_id}",
33
+ 'runtime_in_seconds' => 0,
34
+ }
35
+ mock_data[:jobs] << job_data
36
+ {
37
+ :body => Xplenty::API::OkJson.encode(job_data),
38
+ :status => 201
39
+ }
40
+ end
41
+ end
42
+
43
+
44
+ # stub GET /:account_id/api/jobs
45
+ Excon.stub(:expects => 200, :method => :get, :path => %r{^/\w+/api/jobs$} ) do |params|
46
+ request_params, mock_data = parse_stub_params(params)
47
+ {
48
+ :body => Xplenty::API::OkJson.encode(mock_data[:jobs]),
49
+ :status => 200
50
+ }
51
+ end
52
+
53
+ # stub GET /:account_id/api/jobs/:job_id
54
+ Excon.stub(:expects => 200, :method => :get, :path => %r{^/\w+/api/jobs/([^/]+)$} ) do |params|
55
+ request_params, mock_data = parse_stub_params(params)
56
+ job_id, _ = request_params[:captures][:path]
57
+
58
+ with_mock_job(mock_data, job_id) do |job_data|
59
+ {
60
+ :body => Xplenty::API::OkJson.encode(job_data),
61
+ :status => 200
62
+ }
63
+ end
64
+ end
65
+
66
+ # stub DELETE /:account_id/api/jobs/:job_id
67
+ Excon.stub(:expects => 200, :method => :delete, :path => %r{^/\w+/api/jobs/([^/]+)$} ) do |params|
68
+ request_params, mock_data = parse_stub_params(params)
69
+ job_id, _ = request_params[:captures][:path]
70
+
71
+ job = get_mock_job(mock_data, job_id)
72
+
73
+ if job
74
+ mock_data[:jobs].delete job
75
+ {
76
+ :body => Xplenty::API::OkJson.encode(job),
77
+ :status => 200
78
+ }
79
+ else
80
+ JOB_NOT_FOUND
81
+ end
82
+ end
83
+
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,600 @@
1
+ # encoding: UTF-8
2
+ #
3
+ # Copyright 2011, 2012 Keith Rarick
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ # See https://github.com/kr/okjson for updates.
24
+
25
+ require 'stringio'
26
+
27
+ # Some parts adapted from
28
+ # http://golang.org/src/pkg/json/decode.go and
29
+ # http://golang.org/src/pkg/utf8/utf8.go
30
+ module Xplenty
31
+ class API
32
+ module OkJson
33
+ extend self
34
+
35
+
36
+ # Decodes a json document in string s and
37
+ # returns the corresponding ruby value.
38
+ # String s must be valid UTF-8. If you have
39
+ # a string in some other encoding, convert
40
+ # it first.
41
+ #
42
+ # String values in the resulting structure
43
+ # will be UTF-8.
44
+ def decode(s)
45
+ ts = lex(s)
46
+ v, ts = textparse(ts)
47
+ if ts.length > 0
48
+ raise Error, 'trailing garbage'
49
+ end
50
+ v
51
+ end
52
+
53
+
54
+ # Parses a "json text" in the sense of RFC 4627.
55
+ # Returns the parsed value and any trailing tokens.
56
+ # Note: this is almost the same as valparse,
57
+ # except that it does not accept atomic values.
58
+ def textparse(ts)
59
+ if ts.length < 0
60
+ raise Error, 'empty'
61
+ end
62
+
63
+ typ, _, val = ts[0]
64
+ case typ
65
+ when '{' then objparse(ts)
66
+ when '[' then arrparse(ts)
67
+ else
68
+ raise Error, "unexpected #{val.inspect}"
69
+ end
70
+ end
71
+
72
+
73
+ # Parses a "value" in the sense of RFC 4627.
74
+ # Returns the parsed value and any trailing tokens.
75
+ def valparse(ts)
76
+ if ts.length < 0
77
+ raise Error, 'empty'
78
+ end
79
+
80
+ typ, _, val = ts[0]
81
+ case typ
82
+ when '{' then objparse(ts)
83
+ when '[' then arrparse(ts)
84
+ when :val,:str then [val, ts[1..-1]]
85
+ else
86
+ raise Error, "unexpected #{val.inspect}"
87
+ end
88
+ end
89
+
90
+
91
+ # Parses an "object" in the sense of RFC 4627.
92
+ # Returns the parsed value and any trailing tokens.
93
+ def objparse(ts)
94
+ ts = eat('{', ts)
95
+ obj = {}
96
+
97
+ if ts[0][0] == '}'
98
+ return obj, ts[1..-1]
99
+ end
100
+
101
+ k, v, ts = pairparse(ts)
102
+ obj[k] = v
103
+
104
+ if ts[0][0] == '}'
105
+ return obj, ts[1..-1]
106
+ end
107
+
108
+ loop do
109
+ ts = eat(',', ts)
110
+
111
+ k, v, ts = pairparse(ts)
112
+ obj[k] = v
113
+
114
+ if ts[0][0] == '}'
115
+ return obj, ts[1..-1]
116
+ end
117
+ end
118
+ end
119
+
120
+
121
+ # Parses a "member" in the sense of RFC 4627.
122
+ # Returns the parsed values and any trailing tokens.
123
+ def pairparse(ts)
124
+ (typ, _, k), ts = ts[0], ts[1..-1]
125
+ if typ != :str
126
+ raise Error, "unexpected #{k.inspect}"
127
+ end
128
+ ts = eat(':', ts)
129
+ v, ts = valparse(ts)
130
+ [k, v, ts]
131
+ end
132
+
133
+
134
+ # Parses an "array" in the sense of RFC 4627.
135
+ # Returns the parsed value and any trailing tokens.
136
+ def arrparse(ts)
137
+ ts = eat('[', ts)
138
+ arr = []
139
+
140
+ if ts[0][0] == ']'
141
+ return arr, ts[1..-1]
142
+ end
143
+
144
+ v, ts = valparse(ts)
145
+ arr << v
146
+
147
+ if ts[0][0] == ']'
148
+ return arr, ts[1..-1]
149
+ end
150
+
151
+ loop do
152
+ ts = eat(',', ts)
153
+
154
+ v, ts = valparse(ts)
155
+ arr << v
156
+
157
+ if ts[0][0] == ']'
158
+ return arr, ts[1..-1]
159
+ end
160
+ end
161
+ end
162
+
163
+
164
+ def eat(typ, ts)
165
+ if ts[0][0] != typ
166
+ raise Error, "expected #{typ} (got #{ts[0].inspect})"
167
+ end
168
+ ts[1..-1]
169
+ end
170
+
171
+
172
+ # Scans s and returns a list of json tokens,
173
+ # excluding white space (as defined in RFC 4627).
174
+ def lex(s)
175
+ ts = []
176
+ while s.length > 0
177
+ typ, lexeme, val = tok(s)
178
+ if typ == nil
179
+ raise Error, "invalid character at #{s[0,10].inspect}"
180
+ end
181
+ if typ != :space
182
+ ts << [typ, lexeme, val]
183
+ end
184
+ s = s[lexeme.length..-1]
185
+ end
186
+ ts
187
+ end
188
+
189
+
190
+ # Scans the first token in s and
191
+ # returns a 3-element list, or nil
192
+ # if s does not begin with a valid token.
193
+ #
194
+ # The first list element is one of
195
+ # '{', '}', ':', ',', '[', ']',
196
+ # :val, :str, and :space.
197
+ #
198
+ # The second element is the lexeme.
199
+ #
200
+ # The third element is the value of the
201
+ # token for :val and :str, otherwise
202
+ # it is the lexeme.
203
+ def tok(s)
204
+ case s[0]
205
+ when ?{ then ['{', s[0,1], s[0,1]]
206
+ when ?} then ['}', s[0,1], s[0,1]]
207
+ when ?: then [':', s[0,1], s[0,1]]
208
+ when ?, then [',', s[0,1], s[0,1]]
209
+ when ?[ then ['[', s[0,1], s[0,1]]
210
+ when ?] then [']', s[0,1], s[0,1]]
211
+ when ?n then nulltok(s)
212
+ when ?t then truetok(s)
213
+ when ?f then falsetok(s)
214
+ when ?" then strtok(s)
215
+ when Spc then [:space, s[0,1], s[0,1]]
216
+ when ?\t then [:space, s[0,1], s[0,1]]
217
+ when ?\n then [:space, s[0,1], s[0,1]]
218
+ when ?\r then [:space, s[0,1], s[0,1]]
219
+ else numtok(s)
220
+ end
221
+ end
222
+
223
+
224
+ def nulltok(s); s[0,4] == 'null' ? [:val, 'null', nil] : [] end
225
+ def truetok(s); s[0,4] == 'true' ? [:val, 'true', true] : [] end
226
+ def falsetok(s); s[0,5] == 'false' ? [:val, 'false', false] : [] end
227
+
228
+
229
+ def numtok(s)
230
+ m = /-?([1-9][0-9]+|[0-9])([.][0-9]+)?([eE][+-]?[0-9]+)?/.match(s)
231
+ if m && m.begin(0) == 0
232
+ if m[3] && !m[2]
233
+ [:val, m[0], Integer(m[1])*(10**Integer(m[3][1..-1]))]
234
+ elsif m[2]
235
+ [:val, m[0], Float(m[0])]
236
+ else
237
+ [:val, m[0], Integer(m[0])]
238
+ end
239
+ else
240
+ []
241
+ end
242
+ end
243
+
244
+
245
+ def strtok(s)
246
+ m = /"([^"\\]|\\["\/\\bfnrt]|\\u[0-9a-fA-F]{4})*"/.match(s)
247
+ if ! m
248
+ raise Error, "invalid string literal at #{abbrev(s)}"
249
+ end
250
+ [:str, m[0], unquote(m[0])]
251
+ end
252
+
253
+
254
+ def abbrev(s)
255
+ t = s[0,10]
256
+ p = t['`']
257
+ t = t[0,p] if p
258
+ t = t + '...' if t.length < s.length
259
+ '`' + t + '`'
260
+ end
261
+
262
+
263
+ # Converts a quoted json string literal q into a UTF-8-encoded string.
264
+ # The rules are different than for Ruby, so we cannot use eval.
265
+ # Unquote will raise an error if q contains control characters.
266
+ def unquote(q)
267
+ q = q[1...-1]
268
+ rubydoesenc = false
269
+ # In ruby >= 1.9, a[w] is a codepoint, not a byte.
270
+ if q.class.method_defined?(:force_encoding)
271
+ q.force_encoding('UTF-8')
272
+ rubydoesenc = true
273
+ end
274
+ a = q.dup # allocate a big enough string
275
+ r, w = 0, 0
276
+ while r < q.length
277
+ c = q[r]
278
+ case true
279
+ when c == ?\\
280
+ r += 1
281
+ if r >= q.length
282
+ raise Error, "string literal ends with a \"\\\": \"#{q}\""
283
+ end
284
+
285
+ case q[r]
286
+ when ?",?\\,?/,?'
287
+ a[w] = q[r]
288
+ r += 1
289
+ w += 1
290
+ when ?b,?f,?n,?r,?t
291
+ a[w] = Unesc[q[r]]
292
+ r += 1
293
+ w += 1
294
+ when ?u
295
+ r += 1
296
+ uchar = begin
297
+ hexdec4(q[r,4])
298
+ rescue RuntimeError => e
299
+ raise Error, "invalid escape sequence \\u#{q[r,4]}: #{e}"
300
+ end
301
+ r += 4
302
+ if surrogate? uchar
303
+ if q.length >= r+6
304
+ uchar1 = hexdec4(q[r+2,4])
305
+ uchar = subst(uchar, uchar1)
306
+ if uchar != Ucharerr
307
+ # A valid pair; consume.
308
+ r += 6
309
+ end
310
+ end
311
+ end
312
+ if rubydoesenc
313
+ a[w] = '' << uchar
314
+ w += 1
315
+ else
316
+ w += ucharenc(a, w, uchar)
317
+ end
318
+ else
319
+ raise Error, "invalid escape char #{q[r]} in \"#{q}\""
320
+ end
321
+ when c == ?", c < Spc
322
+ raise Error, "invalid character in string literal \"#{q}\""
323
+ else
324
+ # Copy anything else byte-for-byte.
325
+ # Valid UTF-8 will remain valid UTF-8.
326
+ # Invalid UTF-8 will remain invalid UTF-8.
327
+ # In ruby >= 1.9, c is a codepoint, not a byte,
328
+ # in which case this is still what we want.
329
+ a[w] = c
330
+ r += 1
331
+ w += 1
332
+ end
333
+ end
334
+ a[0,w]
335
+ end
336
+
337
+
338
+ # Encodes unicode character u as UTF-8
339
+ # bytes in string a at position i.
340
+ # Returns the number of bytes written.
341
+ def ucharenc(a, i, u)
342
+ case true
343
+ when u <= Uchar1max
344
+ a[i] = (u & 0xff).chr
345
+ 1
346
+ when u <= Uchar2max
347
+ a[i+0] = (Utag2 | ((u>>6)&0xff)).chr
348
+ a[i+1] = (Utagx | (u&Umaskx)).chr
349
+ 2
350
+ when u <= Uchar3max
351
+ a[i+0] = (Utag3 | ((u>>12)&0xff)).chr
352
+ a[i+1] = (Utagx | ((u>>6)&Umaskx)).chr
353
+ a[i+2] = (Utagx | (u&Umaskx)).chr
354
+ 3
355
+ else
356
+ a[i+0] = (Utag4 | ((u>>18)&0xff)).chr
357
+ a[i+1] = (Utagx | ((u>>12)&Umaskx)).chr
358
+ a[i+2] = (Utagx | ((u>>6)&Umaskx)).chr
359
+ a[i+3] = (Utagx | (u&Umaskx)).chr
360
+ 4
361
+ end
362
+ end
363
+
364
+
365
+ def hexdec4(s)
366
+ if s.length != 4
367
+ raise Error, 'short'
368
+ end
369
+ (nibble(s[0])<<12) | (nibble(s[1])<<8) | (nibble(s[2])<<4) | nibble(s[3])
370
+ end
371
+
372
+
373
+ def subst(u1, u2)
374
+ if Usurr1 <= u1 && u1 < Usurr2 && Usurr2 <= u2 && u2 < Usurr3
375
+ return ((u1-Usurr1)<<10) | (u2-Usurr2) + Usurrself
376
+ end
377
+ return Ucharerr
378
+ end
379
+
380
+
381
+ def surrogate?(u)
382
+ Usurr1 <= u && u < Usurr3
383
+ end
384
+
385
+
386
+ def nibble(c)
387
+ case true
388
+ when ?0 <= c && c <= ?9 then c.ord - ?0.ord
389
+ when ?a <= c && c <= ?z then c.ord - ?a.ord + 10
390
+ when ?A <= c && c <= ?Z then c.ord - ?A.ord + 10
391
+ else
392
+ raise Error, "invalid hex code #{c}"
393
+ end
394
+ end
395
+
396
+
397
+ # Encodes x into a json text. It may contain only
398
+ # Array, Hash, String, Numeric, true, false, nil.
399
+ # (Note, this list excludes Symbol.)
400
+ # X itself must be an Array or a Hash.
401
+ # No other value can be encoded, and an error will
402
+ # be raised if x contains any other value, such as
403
+ # Nan, Infinity, Symbol, and Proc, or if a Hash key
404
+ # is not a String.
405
+ # Strings contained in x must be valid UTF-8.
406
+ def encode(x)
407
+ case x
408
+ when Hash then objenc(x)
409
+ when Array then arrenc(x)
410
+ else
411
+ raise Error, 'root value must be an Array or a Hash'
412
+ end
413
+ end
414
+
415
+
416
+ def valenc(x)
417
+ case x
418
+ when Hash then objenc(x)
419
+ when Array then arrenc(x)
420
+ when String then strenc(x)
421
+ when Numeric then numenc(x)
422
+ when true then "true"
423
+ when false then "false"
424
+ when nil then "null"
425
+ else
426
+ raise Error, "cannot encode #{x.class}: #{x.inspect}"
427
+ end
428
+ end
429
+
430
+
431
+ def objenc(x)
432
+ '{' + x.map{|k,v| keyenc(k) + ':' + valenc(v)}.join(',') + '}'
433
+ end
434
+
435
+
436
+ def arrenc(a)
437
+ '[' + a.map{|x| valenc(x)}.join(',') + ']'
438
+ end
439
+
440
+
441
+ def keyenc(k)
442
+ case k
443
+ when String then strenc(k)
444
+ else
445
+ raise Error, "Hash key is not a string: #{k.inspect}"
446
+ end
447
+ end
448
+
449
+
450
+ def strenc(s)
451
+ t = StringIO.new
452
+ t.putc(?")
453
+ r = 0
454
+
455
+ # In ruby >= 1.9, s[r] is a codepoint, not a byte.
456
+ rubydoesenc = s.class.method_defined?(:encoding)
457
+
458
+ while r < s.length
459
+ case s[r]
460
+ when ?" then t.print('\\"')
461
+ when ?\\ then t.print('\\\\')
462
+ when ?\b then t.print('\\b')
463
+ when ?\f then t.print('\\f')
464
+ when ?\n then t.print('\\n')
465
+ when ?\r then t.print('\\r')
466
+ when ?\t then t.print('\\t')
467
+ else
468
+ c = s[r]
469
+ case true
470
+ when rubydoesenc
471
+ begin
472
+ c.ord # will raise an error if c is invalid UTF-8
473
+ t.write(c)
474
+ rescue
475
+ t.write(Ustrerr)
476
+ end
477
+ when Spc <= c && c <= ?~
478
+ t.putc(c)
479
+ else
480
+ n = ucharcopy(t, s, r) # ensure valid UTF-8 output
481
+ r += n - 1 # r is incremented below
482
+ end
483
+ end
484
+ r += 1
485
+ end
486
+ t.putc(?")
487
+ t.string
488
+ end
489
+
490
+
491
+ def numenc(x)
492
+ if ((x.nan? || x.infinite?) rescue false)
493
+ raise Error, "Numeric cannot be represented: #{x}"
494
+ end
495
+ "#{x}"
496
+ end
497
+
498
+
499
+ # Copies the valid UTF-8 bytes of a single character
500
+ # from string s at position i to I/O object t, and
501
+ # returns the number of bytes copied.
502
+ # If no valid UTF-8 char exists at position i,
503
+ # ucharcopy writes Ustrerr and returns 1.
504
+ def ucharcopy(t, s, i)
505
+ n = s.length - i
506
+ raise Utf8Error if n < 1
507
+
508
+ c0 = s[i].ord
509
+
510
+ # 1-byte, 7-bit sequence?
511
+ if c0 < Utagx
512
+ t.putc(c0)
513
+ return 1
514
+ end
515
+
516
+ raise Utf8Error if c0 < Utag2 # unexpected continuation byte?
517
+
518
+ raise Utf8Error if n < 2 # need continuation byte
519
+ c1 = s[i+1].ord
520
+ raise Utf8Error if c1 < Utagx || Utag2 <= c1
521
+
522
+ # 2-byte, 11-bit sequence?
523
+ if c0 < Utag3
524
+ raise Utf8Error if ((c0&Umask2)<<6 | (c1&Umaskx)) <= Uchar1max
525
+ t.putc(c0)
526
+ t.putc(c1)
527
+ return 2
528
+ end
529
+
530
+ # need second continuation byte
531
+ raise Utf8Error if n < 3
532
+
533
+ c2 = s[i+2].ord
534
+ raise Utf8Error if c2 < Utagx || Utag2 <= c2
535
+
536
+ # 3-byte, 16-bit sequence?
537
+ if c0 < Utag4
538
+ u = (c0&Umask3)<<12 | (c1&Umaskx)<<6 | (c2&Umaskx)
539
+ raise Utf8Error if u <= Uchar2max
540
+ t.putc(c0)
541
+ t.putc(c1)
542
+ t.putc(c2)
543
+ return 3
544
+ end
545
+
546
+ # need third continuation byte
547
+ raise Utf8Error if n < 4
548
+ c3 = s[i+3].ord
549
+ raise Utf8Error if c3 < Utagx || Utag2 <= c3
550
+
551
+ # 4-byte, 21-bit sequence?
552
+ if c0 < Utag5
553
+ u = (c0&Umask4)<<18 | (c1&Umaskx)<<12 | (c2&Umaskx)<<6 | (c3&Umaskx)
554
+ raise Utf8Error if u <= Uchar3max
555
+ t.putc(c0)
556
+ t.putc(c1)
557
+ t.putc(c2)
558
+ t.putc(c3)
559
+ return 4
560
+ end
561
+
562
+ raise Utf8Error
563
+ rescue Utf8Error
564
+ t.write(Ustrerr)
565
+ return 1
566
+ end
567
+
568
+
569
+ class Utf8Error < ::StandardError
570
+ end
571
+
572
+
573
+ class Error < ::StandardError
574
+ end
575
+
576
+
577
+ Utagx = 0x80 # 1000 0000
578
+ Utag2 = 0xc0 # 1100 0000
579
+ Utag3 = 0xe0 # 1110 0000
580
+ Utag4 = 0xf0 # 1111 0000
581
+ Utag5 = 0xF8 # 1111 1000
582
+ Umaskx = 0x3f # 0011 1111
583
+ Umask2 = 0x1f # 0001 1111
584
+ Umask3 = 0x0f # 0000 1111
585
+ Umask4 = 0x07 # 0000 0111
586
+ Uchar1max = (1<<7) - 1
587
+ Uchar2max = (1<<11) - 1
588
+ Uchar3max = (1<<16) - 1
589
+ Ucharerr = 0xFFFD # unicode "replacement char"
590
+ Ustrerr = "\xef\xbf\xbd" # unicode "replacement char"
591
+ Usurrself = 0x10000
592
+ Usurr1 = 0xd800
593
+ Usurr2 = 0xdc00
594
+ Usurr3 = 0xe000
595
+
596
+ Spc = ' '[0]
597
+ Unesc = {?b=>?\b, ?f=>?\f, ?n=>?\n, ?r=>?\r, ?t=>?\t}
598
+ end
599
+ end
600
+ end