eipiai 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/.gitignore +10 -0
- data/.hubbit.yml +0 -0
- data/.rubocop.yml +5 -0
- data/.wercker.yml +12 -0
- data/Gemfile +2 -0
- data/README.md +84 -0
- data/Rakefile +37 -0
- data/eipiai.gemspec +35 -0
- data/features/support/app.rb +27 -0
- data/features/support/db.rb +8 -0
- data/features/support/env.rb +6 -0
- data/features/webmachine.feature +134 -0
- data/lib/eipiai/roar/representers/api.rb +44 -0
- data/lib/eipiai/roar.rb +3 -0
- data/lib/eipiai/version.rb +7 -0
- data/lib/eipiai/webmachine/ext/decision.rb +66 -0
- data/lib/eipiai/webmachine/ext/request.rb +37 -0
- data/lib/eipiai/webmachine/resources/api.rb +31 -0
- data/lib/eipiai/webmachine/resources/base.rb +196 -0
- data/lib/eipiai/webmachine/resources/collection.rb +62 -0
- data/lib/eipiai/webmachine/resources/singular.rb +66 -0
- data/lib/eipiai/webmachine.rb +9 -0
- data/lib/eipiai.rb +2 -0
- data/script/bootstrap +5 -0
- data/script/documentation +4 -0
- data/script/test +3 -0
- metadata +280 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 877a72bd72e8b2b172a6f1d5e534c938b12dc675
|
4
|
+
data.tar.gz: 3b11eae2304fa9a61a2ccb45e78eb0c2a1be17ff
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ea7009b9c493cb4e757c5ee6f3a19a0db3ead47df7c5dcd555acf73e01565e97469529a596f031924331821029241d3ccbab328092b0caade6cb7d5c7b896f75
|
7
|
+
data.tar.gz: 5a1a3e46d6ed34468c386d1388315a9d2d2235563bbfa1c9b0e00906d97958b7cfa87ef5aecffaec29f8647723bac4e234d8046a637737d0baab520319d37975
|
data/.gitignore
ADDED
data/.hubbit.yml
ADDED
File without changes
|
data/.rubocop.yml
ADDED
data/.wercker.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# `/ˌeɪ.piˈaɪ/` – The JSON API Framework
|
2
|
+
|
3
|
+
Opinionated JSON-API stack to get the job done.
|
4
|
+
|
5
|
+
This library brings some of the _best_\* Ruby JSON API libraries together,
|
6
|
+
provides the glue to make things easy to work with out-of-the-box, and even
|
7
|
+
easier to modify to your own liking.
|
8
|
+
|
9
|
+
\* _in our humble opinion_
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
More detailed installation instructions will follow. But for now, here's an
|
14
|
+
example:
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
gem 'eipiai'
|
18
|
+
gem 'roar'
|
19
|
+
gem 'sequel'
|
20
|
+
gem 'sqlite3'
|
21
|
+
```
|
22
|
+
|
23
|
+
```sh
|
24
|
+
bundle install
|
25
|
+
```
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
require 'eipiai'
|
29
|
+
require 'roar/decorator'
|
30
|
+
require 'roar/json/hal'
|
31
|
+
require 'sequel'
|
32
|
+
require 'sqlite3'
|
33
|
+
|
34
|
+
DB = Sequel.sqlite
|
35
|
+
DB.create_table(:items) do
|
36
|
+
primary_key :id
|
37
|
+
String :uid
|
38
|
+
end
|
39
|
+
|
40
|
+
class Item < Sequel::Model
|
41
|
+
end
|
42
|
+
|
43
|
+
class ItemRepresenter < Roar::Decorator
|
44
|
+
include Roar::JSON::HAL
|
45
|
+
property :uid
|
46
|
+
|
47
|
+
def path
|
48
|
+
"/item/#{represented.uid}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class ItemsRepresenter < Roar::Decorator
|
53
|
+
include Roar::JSON::HAL
|
54
|
+
collection :items, getter: proc { Item.all }, decorator: ItemRepresenter
|
55
|
+
|
56
|
+
def path
|
57
|
+
'/items'
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class ItemResource < Webmachine::Resource
|
62
|
+
include Eipiai::Resource
|
63
|
+
end
|
64
|
+
|
65
|
+
class ItemsResource < Webmachine::Resource
|
66
|
+
include Eipiai::Resource
|
67
|
+
end
|
68
|
+
|
69
|
+
App = Webmachine::Application.new do |app|
|
70
|
+
app.routes do
|
71
|
+
add ['items'], ItemsResource
|
72
|
+
add ['item', :item_uid], ItemResource
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
App.run
|
77
|
+
```
|
78
|
+
|
79
|
+
```sh
|
80
|
+
bundle exec ruby app.rb &
|
81
|
+
curl -XPOST -d'{"uid":"awesome"}' -HContent-Type:application/json localhost:8080/items
|
82
|
+
curl localhost:8080/item/awesome
|
83
|
+
# {"uid":"awesome"}
|
84
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'cucumber/rake/task'
|
3
|
+
require 'rubocop/rake_task'
|
4
|
+
require 'yard-doctest'
|
5
|
+
|
6
|
+
task default: %w(rubocop features yard:doctest)
|
7
|
+
|
8
|
+
RuboCop::RakeTask.new
|
9
|
+
|
10
|
+
desc 'Update CHANGELOG.md'
|
11
|
+
task 'changelog' do
|
12
|
+
args = %w(
|
13
|
+
--user=blendle
|
14
|
+
--project=eipiai
|
15
|
+
--header-label="# CHANGELOG"
|
16
|
+
--bug-labels=type/bug,bug
|
17
|
+
--enhancement-labels=type/enhancement,enhancement
|
18
|
+
--future-release=unreleased
|
19
|
+
--no-verbose
|
20
|
+
)
|
21
|
+
|
22
|
+
sh %(github_changelog_generator #{args.join(' ')})
|
23
|
+
end
|
24
|
+
|
25
|
+
YARD::Doctest::RakeTask.new do |task|
|
26
|
+
task.doctest_opts = %w(--pride)
|
27
|
+
end
|
28
|
+
|
29
|
+
YARD::Rake::YardocTask.new do |task|
|
30
|
+
task.files = %w(lib/**/*.rb)
|
31
|
+
task.options = %w(--markup markdown --readme README.md)
|
32
|
+
end
|
33
|
+
|
34
|
+
Cucumber::Rake::Task.new(:features) do |t|
|
35
|
+
t.cucumber_opts = 'features --format pretty --tags ~@wip --no-source ' \
|
36
|
+
'--color --strict --expand --order random'
|
37
|
+
end
|
data/eipiai.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('lib', __dir__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'eipiai/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'eipiai'
|
8
|
+
spec.version = Eipiai::VERSION
|
9
|
+
spec.authors = ['Jean Mertz']
|
10
|
+
spec.email = %w(jean@mertz.fm)
|
11
|
+
spec.summary = 'Opinionated JSON-API stack to get the job done.'
|
12
|
+
spec.homepage = 'https://github.com/blendle/eipiai'
|
13
|
+
|
14
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(support)/}) }
|
15
|
+
spec.bindir = 'bin'
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.require_paths = %w(lib)
|
18
|
+
|
19
|
+
spec.add_runtime_dependency 'activesupport'
|
20
|
+
spec.add_runtime_dependency 'addressable'
|
21
|
+
spec.add_runtime_dependency 'roar'
|
22
|
+
spec.add_runtime_dependency 'webmachine'
|
23
|
+
|
24
|
+
spec.add_development_dependency 'bundler'
|
25
|
+
spec.add_development_dependency 'cucumber'
|
26
|
+
spec.add_development_dependency 'cucumber-blendle-steps'
|
27
|
+
spec.add_development_dependency 'database_cleaner'
|
28
|
+
spec.add_development_dependency 'pry'
|
29
|
+
spec.add_development_dependency 'rack-test'
|
30
|
+
spec.add_development_dependency 'rack'
|
31
|
+
spec.add_development_dependency 'rake'
|
32
|
+
spec.add_development_dependency 'rubocop'
|
33
|
+
spec.add_development_dependency 'sqlite3'
|
34
|
+
spec.add_development_dependency 'yard-doctest'
|
35
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
lib = File.expand_path('../lib', __dir__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
|
4
|
+
require 'eipiai'
|
5
|
+
require 'webmachine/adapters/rack'
|
6
|
+
require_relative '../../support/fixtures/webmachine/app'
|
7
|
+
|
8
|
+
include Eipiai::Webmachine::TestApp
|
9
|
+
|
10
|
+
App = Webmachine::Application.new do |app|
|
11
|
+
app.routes do
|
12
|
+
add ['api'], Eipiai::ApiResource
|
13
|
+
|
14
|
+
add ['items'], ItemsResource
|
15
|
+
add ['item', :item_uid], ItemResource
|
16
|
+
end
|
17
|
+
|
18
|
+
app.configure do |config|
|
19
|
+
config.adapter = :Rack
|
20
|
+
end
|
21
|
+
|
22
|
+
Eipiai::ApiRepresenter.populate_from_app(app)
|
23
|
+
end
|
24
|
+
|
25
|
+
def app
|
26
|
+
App.adapter
|
27
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
Feature: Webmachine CRUD operations
|
2
|
+
|
3
|
+
When a new JSON API service needs to be developed
|
4
|
+
I want to use Eipiai's default CRUD setup
|
5
|
+
So that the basic API operations are quick and easyi to implement
|
6
|
+
|
7
|
+
Scenario: List main API endpoint
|
8
|
+
When the client does a GET request to "/api"
|
9
|
+
Then the status code should be "200" (OK)
|
10
|
+
And the response should be HAL/JSON:
|
11
|
+
"""json
|
12
|
+
{
|
13
|
+
"_links": {
|
14
|
+
"self": {
|
15
|
+
"href": "https://example.org/api"
|
16
|
+
},
|
17
|
+
"api": {
|
18
|
+
"href": "https://example.org/api"
|
19
|
+
},
|
20
|
+
"items": {
|
21
|
+
"href": "https://example.org/items"
|
22
|
+
},
|
23
|
+
"item": {
|
24
|
+
"templated": true,
|
25
|
+
"href": "https://example.org/item/{item_uid}"
|
26
|
+
}
|
27
|
+
}
|
28
|
+
}
|
29
|
+
"""
|
30
|
+
|
31
|
+
Scenario: Create resource
|
32
|
+
Given the client provides the header "Content-Type: application/json"
|
33
|
+
When the client does a POST request to the "items" resource with the following content:
|
34
|
+
"""json
|
35
|
+
{
|
36
|
+
"uid": "hello",
|
37
|
+
"price": 10
|
38
|
+
}
|
39
|
+
"""
|
40
|
+
Then the status code should be "201" (Created)
|
41
|
+
And the response contains the "Location" header with value "https://example.org/item/hello"
|
42
|
+
And the response should be HAL/JSON:
|
43
|
+
"""json
|
44
|
+
{
|
45
|
+
"_links": {
|
46
|
+
"self": {
|
47
|
+
"href": "https://example.org/item/hello"
|
48
|
+
}
|
49
|
+
},
|
50
|
+
"uid": "hello",
|
51
|
+
"price": 10
|
52
|
+
}
|
53
|
+
"""
|
54
|
+
|
55
|
+
Scenario: Get resource
|
56
|
+
Given the item with uid "hello" and the price "10" exists
|
57
|
+
When the client does a GET request to the "item" resource with the template variable "item_uid" set to "hello"
|
58
|
+
Then the status code should be "200" (OK)
|
59
|
+
And the response should be HAL/JSON:
|
60
|
+
"""json
|
61
|
+
{
|
62
|
+
"_links": {
|
63
|
+
"self": {
|
64
|
+
"href": "https://example.org/item/hello"
|
65
|
+
}
|
66
|
+
},
|
67
|
+
"uid": "hello",
|
68
|
+
"price": 10
|
69
|
+
}
|
70
|
+
"""
|
71
|
+
|
72
|
+
Scenario: Update resource
|
73
|
+
Given the item with uid "hello" and the price "10" exists
|
74
|
+
Given the client provides the header "Content-Type: application/json"
|
75
|
+
When the client does a PUT request to the "item" resource with the template variable "item_uid" set to "hello" and the following content:
|
76
|
+
"""json
|
77
|
+
{
|
78
|
+
"price": 100
|
79
|
+
}
|
80
|
+
"""
|
81
|
+
Then the status code should be "204" (No Content)
|
82
|
+
And the response contains the "Location" header with value "https://example.org/item/hello"
|
83
|
+
|
84
|
+
When the client does a GET request to the "item" resource with the template variable "item_uid" set to "hello"
|
85
|
+
Then the status code should be "200" (OK)
|
86
|
+
And the response should be HAL/JSON:
|
87
|
+
"""json
|
88
|
+
{
|
89
|
+
"_links": {
|
90
|
+
"self": {
|
91
|
+
"href": "https://example.org/item/hello"
|
92
|
+
}
|
93
|
+
},
|
94
|
+
"uid": "hello",
|
95
|
+
"price": 100
|
96
|
+
}
|
97
|
+
"""
|
98
|
+
|
99
|
+
Scenario: List resources
|
100
|
+
Given the item with uid "hello" and the price "10" exists
|
101
|
+
When the client does a GET request to the "items" resource
|
102
|
+
Then the status code should be "200" (OK)
|
103
|
+
And the response should be HAL/JSON:
|
104
|
+
"""json
|
105
|
+
{
|
106
|
+
"_links": {
|
107
|
+
"self": {
|
108
|
+
"href": "https://example.org/items"
|
109
|
+
},
|
110
|
+
"b:items": [
|
111
|
+
{
|
112
|
+
"href": "https://example.org/item/hello"
|
113
|
+
}
|
114
|
+
]
|
115
|
+
},
|
116
|
+
"b:items": [
|
117
|
+
{
|
118
|
+
"_links": {
|
119
|
+
"self": {
|
120
|
+
"href": "https://example.org/item/hello"
|
121
|
+
}
|
122
|
+
},
|
123
|
+
"uid": "hello",
|
124
|
+
"price": 10
|
125
|
+
}
|
126
|
+
]
|
127
|
+
}
|
128
|
+
"""
|
129
|
+
|
130
|
+
Scenario: Delete resource
|
131
|
+
Given the item with uid "hello" exists
|
132
|
+
When the client does a DELETE request to the "item" resource with the template variable "item_uid" set to "hello"
|
133
|
+
Then the status code should be "204" (No Content)
|
134
|
+
And the item with uid "hello" should not exist
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'addressable/uri'
|
2
|
+
require 'roar'
|
3
|
+
require 'roar/decorator'
|
4
|
+
require 'roar/json/hal'
|
5
|
+
|
6
|
+
module Eipiai
|
7
|
+
# ApiRepresenter
|
8
|
+
#
|
9
|
+
# This representer returns an object that represents the `/api` endpoint of
|
10
|
+
# the API. It contains all routes as defined in the Webmachine route, and also
|
11
|
+
# tells the client if the route is templated or not.
|
12
|
+
#
|
13
|
+
class ApiRepresenter < Roar::Decorator
|
14
|
+
include Roar::JSON::HAL
|
15
|
+
|
16
|
+
link(:self) do |request|
|
17
|
+
request.present? ? Addressable::URI.parse(request.uri).merge(path: path).to_s : path
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.populate_from_app(app)
|
21
|
+
app.routes.each do |route|
|
22
|
+
next unless route.resource.respond_to?(:link_identifier)
|
23
|
+
|
24
|
+
templated = route.path_spec.any? { |r| r.is_a?(Symbol) }
|
25
|
+
options = { rel: route.resource.link_identifier }
|
26
|
+
options.merge!(templated: templated) if templated
|
27
|
+
|
28
|
+
add_route(route, options)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.add_route(route, options)
|
33
|
+
link(options) do |request|
|
34
|
+
path = '/' + route.path_spec.map { |e| e.is_a?(Symbol) ? "{#{e}}" : e }.join('/')
|
35
|
+
|
36
|
+
request.present? ? Addressable::URI.parse(request.uri).merge(path: path).to_s : path
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def path
|
41
|
+
'/api'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/eipiai/roar.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'webmachine'
|
2
|
+
|
3
|
+
module Webmachine
|
4
|
+
class Resource
|
5
|
+
# Callbacks
|
6
|
+
#
|
7
|
+
# @see http://git.io/v3vYg
|
8
|
+
#
|
9
|
+
module Callbacks
|
10
|
+
# unprocessable_entity?
|
11
|
+
#
|
12
|
+
# If the request is semantically correct, but unprocessable due to invalid
|
13
|
+
# data, this should return true, which will result in a '422 Unprocessable
|
14
|
+
# Entity' response.
|
15
|
+
#
|
16
|
+
# Defaults to false.
|
17
|
+
#
|
18
|
+
# @return [true, false] whether the request is unprocessable
|
19
|
+
#
|
20
|
+
def unprocessable_entity?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
# payment_required?
|
25
|
+
#
|
26
|
+
# If payment is required this will result in a '402 Payment Required'
|
27
|
+
# response.
|
28
|
+
#
|
29
|
+
# Defaults to false
|
30
|
+
#
|
31
|
+
# @return [true, false] whether the request requires a payment
|
32
|
+
#
|
33
|
+
def payment_required?
|
34
|
+
false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
module Decision
|
40
|
+
# Flow
|
41
|
+
#
|
42
|
+
# @see http://git.io/v3vYP
|
43
|
+
#
|
44
|
+
module Flow
|
45
|
+
# Malformed?
|
46
|
+
def b9b
|
47
|
+
decision_test(resource.malformed_request?, 400, :b9c)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Unprocessable Entity?
|
51
|
+
def b9c
|
52
|
+
decision_test(resource.unprocessable_entity?, 422, :b8)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Forbidden?
|
56
|
+
def b7
|
57
|
+
decision_test(resource.forbidden?, 403, :b7a)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Payment Required?
|
61
|
+
def b7a
|
62
|
+
decision_test(resource.payment_required?, 402, :b6)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'addressable/uri'
|
2
|
+
require 'webmachine'
|
3
|
+
|
4
|
+
module Webmachine
|
5
|
+
# Request
|
6
|
+
#
|
7
|
+
# @see http://git.io/vWZFi
|
8
|
+
#
|
9
|
+
class Request
|
10
|
+
# URIReplacement
|
11
|
+
#
|
12
|
+
# Swaps `Webmachine::Request#uri` from `URI` to `Addressable::URI`.
|
13
|
+
#
|
14
|
+
module URIReplacement
|
15
|
+
# build_uri
|
16
|
+
#
|
17
|
+
# Given an uri object, and headers, calls `Webmachine::Request#build_uri`
|
18
|
+
# and parses the result using Addressable (if available), or simply
|
19
|
+
# returns the result as-is.
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# get('/items').build_uri(URI('http://example.org'), {}).class # => Addressable::URI
|
23
|
+
#
|
24
|
+
# @param [URI] uri object
|
25
|
+
# @param [hash] headers
|
26
|
+
#
|
27
|
+
# @return [Addressable::URI, URI]
|
28
|
+
# Addressable object, or URI if the addressable object is not loaded
|
29
|
+
#
|
30
|
+
def build_uri(uri, headers)
|
31
|
+
defined?(Addressable) ? Addressable::URI.parse(super) : super
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
prepend URIReplacement
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'eipiai/webmachine/resources/base'
|
2
|
+
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module Eipiai
|
7
|
+
class Api
|
8
|
+
end
|
9
|
+
|
10
|
+
# ApiResource
|
11
|
+
#
|
12
|
+
# The base resource which can be included in regular Webmachine::Resource
|
13
|
+
# objects. It provides sensible defaults for a full-features REST API
|
14
|
+
# endpoint.
|
15
|
+
#
|
16
|
+
class ApiResource < Webmachine::Resource
|
17
|
+
include Resource
|
18
|
+
|
19
|
+
def allowed_methods
|
20
|
+
%w(GET)
|
21
|
+
end
|
22
|
+
|
23
|
+
def cache_header
|
24
|
+
'public, max-age=600, s-maxage=86400'
|
25
|
+
end
|
26
|
+
|
27
|
+
def object
|
28
|
+
Api.new
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
require 'eipiai/webmachine/resources/singular'
|
2
|
+
require 'eipiai/webmachine/resources/collection'
|
3
|
+
|
4
|
+
require 'active_support/core_ext/string/inflections'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module Eipiai
|
8
|
+
# Resource
|
9
|
+
#
|
10
|
+
# The base resource which can be included in regular Webmachine::Resource
|
11
|
+
# objects. It provides sensible defaults for a full-features REST API
|
12
|
+
# endpoint.
|
13
|
+
#
|
14
|
+
module Resource
|
15
|
+
# Includes the correct resource into the class, depending on its name.
|
16
|
+
#
|
17
|
+
# If the resource is called `ItemResource`, the `Item` part is considered
|
18
|
+
# singular, and thus the `SingularResource` is included into the class.
|
19
|
+
#
|
20
|
+
def self.included(base)
|
21
|
+
subject_class_name = base.name.demodulize.chomp('Resource')
|
22
|
+
|
23
|
+
if subject_class_name == subject_class_name.singularize
|
24
|
+
base.send(:include, SingularResource)
|
25
|
+
else
|
26
|
+
base.send(:include, CollectionResource)
|
27
|
+
end
|
28
|
+
|
29
|
+
base.extend(ClassMethods)
|
30
|
+
end
|
31
|
+
|
32
|
+
def content_types_provided
|
33
|
+
[['application/hal+json', :to_json]]
|
34
|
+
end
|
35
|
+
|
36
|
+
def content_types_accepted
|
37
|
+
[['application/json', :from_json]]
|
38
|
+
end
|
39
|
+
|
40
|
+
# new_object
|
41
|
+
#
|
42
|
+
# Return the instantiated object, based on the representer provided by
|
43
|
+
# `singular_representer_class` and the parameters provided by `params`.
|
44
|
+
#
|
45
|
+
# See `Object#new_object` for the version without using representers.
|
46
|
+
#
|
47
|
+
# @example
|
48
|
+
# resource.new_object.class # => Item
|
49
|
+
#
|
50
|
+
# @return [Class, nil] instantiated representer, or nil if not found
|
51
|
+
#
|
52
|
+
def new_object
|
53
|
+
return if singular_representer_class.nil? || object_class.nil?
|
54
|
+
|
55
|
+
@new_object ||= singular_representer_class.new(object_class.new).from_hash(params)
|
56
|
+
end
|
57
|
+
|
58
|
+
# params
|
59
|
+
#
|
60
|
+
# Returns a Hash object, representing the JSOM payload sent in the request
|
61
|
+
# object. Returns an empty hash if request body contains invalid JSON.
|
62
|
+
#
|
63
|
+
# @example Parse valid JSON request
|
64
|
+
# resource.send :params, ('{ "hello": "world" }') # => { 'hello' => 'world' }
|
65
|
+
#
|
66
|
+
# @example Parse invalid JSON request
|
67
|
+
# resource.send :params, 'invalid' #=> {}
|
68
|
+
#
|
69
|
+
# @param [String] body JSON provided as a string
|
70
|
+
# @return [Hash] JSON string, converted to a hash
|
71
|
+
#
|
72
|
+
def params(body = request.body.to_s)
|
73
|
+
@params ||= JSON.parse(body)
|
74
|
+
rescue JSON::ParserError
|
75
|
+
{}
|
76
|
+
end
|
77
|
+
|
78
|
+
# to_hash
|
79
|
+
#
|
80
|
+
# Given an object, calls `#to_hash` on that object,
|
81
|
+
#
|
82
|
+
# If the object's `to_hash` implementation accepts any arguments, the
|
83
|
+
# `request` object is sent as its first argument.
|
84
|
+
#
|
85
|
+
# In practice, this method is used without any parameters, causing the
|
86
|
+
# method to call `represented`, which represents a Roar representer. This in
|
87
|
+
# turn converts the represented object to a HAL/JSON compatible hash
|
88
|
+
# representation.
|
89
|
+
#
|
90
|
+
# @example
|
91
|
+
# item = Item.new(uid: 'hello')
|
92
|
+
# resource.to_hash(item) # => { uid: 'hello' }
|
93
|
+
#
|
94
|
+
# @param [Object] o = represented to call #to_hash on
|
95
|
+
# @return [Hash] hash representation of the object
|
96
|
+
#
|
97
|
+
def to_hash(o = represented)
|
98
|
+
o.method(:to_hash).arity.zero? ? o.to_hash : o.to_hash(request)
|
99
|
+
end
|
100
|
+
|
101
|
+
def to_json
|
102
|
+
to_hash.to_json
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
# object_class_name
|
108
|
+
#
|
109
|
+
# String representation of the "object class", based on the current
|
110
|
+
# resource name.
|
111
|
+
#
|
112
|
+
# If the resource is called `ItemsResource`, object_class_name will be
|
113
|
+
# `Item`. It is expected that this name represents the class of the object
|
114
|
+
# the resource provides access to through the API.
|
115
|
+
#
|
116
|
+
# @example
|
117
|
+
# resource.send :object_class_name # => 'Item'
|
118
|
+
#
|
119
|
+
# @return [String] name of object class
|
120
|
+
#
|
121
|
+
def object_class_name
|
122
|
+
self.class.name.chomp('Resource').demodulize.singularize
|
123
|
+
end
|
124
|
+
|
125
|
+
# object_class
|
126
|
+
#
|
127
|
+
# Same as `object_class_name`, except this method will return a constant
|
128
|
+
# _and_ keeps the namespace intact.
|
129
|
+
#
|
130
|
+
# Returns `nil` if constant does not exist.
|
131
|
+
#
|
132
|
+
# @example
|
133
|
+
# resource.send :object_class # => Item
|
134
|
+
#
|
135
|
+
# @return [Class, nil] object class, or nil if not found
|
136
|
+
#
|
137
|
+
def object_class
|
138
|
+
self.class.name.chomp('Resource').singularize.constantize
|
139
|
+
rescue NameError
|
140
|
+
nil
|
141
|
+
end
|
142
|
+
|
143
|
+
# singular_representer_class
|
144
|
+
#
|
145
|
+
# Constant, representing the representer belonging to the defined
|
146
|
+
# `object`.
|
147
|
+
#
|
148
|
+
# If the resource is called `ItemResource`, the representer will be
|
149
|
+
# `ItemRepresenter`.
|
150
|
+
#
|
151
|
+
# Returns `nil` if constant does not exist.
|
152
|
+
#
|
153
|
+
# @example
|
154
|
+
# resource.send :singular_representer_class # => ItemRepresenter
|
155
|
+
#
|
156
|
+
# @return [Class, nil] representer class, or nil if not found
|
157
|
+
#
|
158
|
+
def singular_representer_class
|
159
|
+
"#{object_class}Representer".constantize
|
160
|
+
rescue NameError
|
161
|
+
nil
|
162
|
+
end
|
163
|
+
|
164
|
+
# collection_representer_class
|
165
|
+
#
|
166
|
+
# Constant, representing the representer belonging to the defined
|
167
|
+
# collection of `object`s.
|
168
|
+
#
|
169
|
+
# If the resource is called `ItemResource`, the representer will be
|
170
|
+
# `ItemsRepresenter`.
|
171
|
+
#
|
172
|
+
# Returns `nil` if constant does not exist.
|
173
|
+
#
|
174
|
+
# @example
|
175
|
+
# resource.send :collection_representer_class # => ItemsRepresenter
|
176
|
+
#
|
177
|
+
# @return [Class, nil] representer class, or nil if not found
|
178
|
+
#
|
179
|
+
def collection_representer_class
|
180
|
+
"#{object_class.to_s.pluralize}Representer".constantize
|
181
|
+
rescue NameError
|
182
|
+
nil
|
183
|
+
end
|
184
|
+
|
185
|
+
# ClassMethods
|
186
|
+
#
|
187
|
+
# These methods will be defined on the class in which this module is
|
188
|
+
# included.
|
189
|
+
#
|
190
|
+
module ClassMethods
|
191
|
+
def link_identifier
|
192
|
+
name.chomp('Resource').demodulize.underscore.to_sym
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'eipiai/webmachine/resources/base'
|
2
|
+
|
3
|
+
module Eipiai
|
4
|
+
# CollectionResource
|
5
|
+
#
|
6
|
+
# The collection resource is the basis for a resource representing a
|
7
|
+
# collection of objects.
|
8
|
+
#
|
9
|
+
# It provides basic GET and POST support.
|
10
|
+
#
|
11
|
+
# It is the collection version of the `SingularResource` resource.
|
12
|
+
#
|
13
|
+
module CollectionResource
|
14
|
+
def allowed_methods
|
15
|
+
%w(GET POST)
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO(JeanMertz): enable when validators are implemented.
|
19
|
+
#
|
20
|
+
# def unprocessable_entity?
|
21
|
+
# super && json_error_body(*new_object.errors)
|
22
|
+
# end
|
23
|
+
|
24
|
+
def post_is_create?
|
25
|
+
true
|
26
|
+
end
|
27
|
+
|
28
|
+
def create_path
|
29
|
+
singular_representer_class.new(new_object).path
|
30
|
+
end
|
31
|
+
|
32
|
+
def from_json
|
33
|
+
new_object.save && handle_post_response
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def content_type_handler
|
39
|
+
content_type = response.headers[::Webmachine::CONTENT_TYPE]
|
40
|
+
media_type = ::Webmachine::MediaType.parse(content_type)
|
41
|
+
|
42
|
+
content_types_provided.find { |ct, _| media_type.type_matches?(ct) }.last
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_post_response
|
46
|
+
response.body = send(content_type_handler)
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
def object
|
51
|
+
@object ||= Struct.new(:dataset).new(object_class.dataset) if object_class
|
52
|
+
end
|
53
|
+
|
54
|
+
def representer_class
|
55
|
+
request.get? ? collection_representer_class : singular_representer_class
|
56
|
+
end
|
57
|
+
|
58
|
+
def represented
|
59
|
+
representer_class.new(request.get? ? object : new_object)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'eipiai/webmachine/resources/base'
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object/blank'
|
4
|
+
require 'active_support/core_ext/string/inflections'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module Eipiai
|
8
|
+
# SingularResource
|
9
|
+
#
|
10
|
+
# The singular resource is the basis for a resource representing a single
|
11
|
+
# object.
|
12
|
+
#
|
13
|
+
# It provides basic GET, PUT and DELETE support.
|
14
|
+
#
|
15
|
+
# It is the singular version of the `CollectionResource` resource.
|
16
|
+
#
|
17
|
+
module SingularResource
|
18
|
+
def allowed_methods
|
19
|
+
%w(GET PUT DELETE)
|
20
|
+
end
|
21
|
+
|
22
|
+
# TODO(JeanMertz): enable when validators are implemented.
|
23
|
+
#
|
24
|
+
# def unprocessable_entity?
|
25
|
+
# return unless super
|
26
|
+
# return if request.put? && new_object.errors.include?(:resource_exists)
|
27
|
+
#
|
28
|
+
# json_error_body(*new_object.errors)
|
29
|
+
# end
|
30
|
+
|
31
|
+
def resource_exists?
|
32
|
+
object.present?
|
33
|
+
end
|
34
|
+
|
35
|
+
def delete_resource
|
36
|
+
object.destroy
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
def from_json
|
41
|
+
delete_resource if (exists = object.present?)
|
42
|
+
new_object.save
|
43
|
+
|
44
|
+
response.headers['Location'] = request.uri.to_s
|
45
|
+
exists ? 204 : 201
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def params
|
51
|
+
@merged_params ||= super.merge('uid' => object_uid)
|
52
|
+
end
|
53
|
+
|
54
|
+
def represented
|
55
|
+
singular_representer_class.new(object)
|
56
|
+
end
|
57
|
+
|
58
|
+
def object
|
59
|
+
@object ||= object_class.first(uid: object_uid) if object_class
|
60
|
+
end
|
61
|
+
|
62
|
+
def object_uid
|
63
|
+
request.path_info[:"#{object_class_name.downcase}_uid"]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
require 'webmachine'
|
2
|
+
|
3
|
+
require 'eipiai/webmachine/ext/decision'
|
4
|
+
require 'eipiai/webmachine/ext/request'
|
5
|
+
|
6
|
+
require 'eipiai/webmachine/resources/api'
|
7
|
+
require 'eipiai/webmachine/resources/base'
|
8
|
+
require 'eipiai/webmachine/resources/collection'
|
9
|
+
require 'eipiai/webmachine/resources/singular'
|
data/lib/eipiai.rb
ADDED
data/script/bootstrap
ADDED
data/script/test
ADDED
metadata
ADDED
@@ -0,0 +1,280 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: eipiai
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jean Mertz
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-12-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: addressable
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: roar
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: webmachine
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: cucumber
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: cucumber-blendle-steps
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: database_cleaner
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: pry
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: rack-test
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: rack
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: rake
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ">="
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: rubocop
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ">="
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - ">="
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '0'
|
195
|
+
- !ruby/object:Gem::Dependency
|
196
|
+
name: sqlite3
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ">="
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '0'
|
202
|
+
type: :development
|
203
|
+
prerelease: false
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
205
|
+
requirements:
|
206
|
+
- - ">="
|
207
|
+
- !ruby/object:Gem::Version
|
208
|
+
version: '0'
|
209
|
+
- !ruby/object:Gem::Dependency
|
210
|
+
name: yard-doctest
|
211
|
+
requirement: !ruby/object:Gem::Requirement
|
212
|
+
requirements:
|
213
|
+
- - ">="
|
214
|
+
- !ruby/object:Gem::Version
|
215
|
+
version: '0'
|
216
|
+
type: :development
|
217
|
+
prerelease: false
|
218
|
+
version_requirements: !ruby/object:Gem::Requirement
|
219
|
+
requirements:
|
220
|
+
- - ">="
|
221
|
+
- !ruby/object:Gem::Version
|
222
|
+
version: '0'
|
223
|
+
description:
|
224
|
+
email:
|
225
|
+
- jean@mertz.fm
|
226
|
+
executables: []
|
227
|
+
extensions: []
|
228
|
+
extra_rdoc_files: []
|
229
|
+
files:
|
230
|
+
- ".gitignore"
|
231
|
+
- ".hubbit.yml"
|
232
|
+
- ".rubocop.yml"
|
233
|
+
- ".wercker.yml"
|
234
|
+
- Gemfile
|
235
|
+
- README.md
|
236
|
+
- Rakefile
|
237
|
+
- eipiai.gemspec
|
238
|
+
- features/support/app.rb
|
239
|
+
- features/support/db.rb
|
240
|
+
- features/support/env.rb
|
241
|
+
- features/webmachine.feature
|
242
|
+
- lib/eipiai.rb
|
243
|
+
- lib/eipiai/roar.rb
|
244
|
+
- lib/eipiai/roar/representers/api.rb
|
245
|
+
- lib/eipiai/version.rb
|
246
|
+
- lib/eipiai/webmachine.rb
|
247
|
+
- lib/eipiai/webmachine/ext/decision.rb
|
248
|
+
- lib/eipiai/webmachine/ext/request.rb
|
249
|
+
- lib/eipiai/webmachine/resources/api.rb
|
250
|
+
- lib/eipiai/webmachine/resources/base.rb
|
251
|
+
- lib/eipiai/webmachine/resources/collection.rb
|
252
|
+
- lib/eipiai/webmachine/resources/singular.rb
|
253
|
+
- script/bootstrap
|
254
|
+
- script/documentation
|
255
|
+
- script/test
|
256
|
+
homepage: https://github.com/blendle/eipiai
|
257
|
+
licenses: []
|
258
|
+
metadata: {}
|
259
|
+
post_install_message:
|
260
|
+
rdoc_options: []
|
261
|
+
require_paths:
|
262
|
+
- lib
|
263
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
264
|
+
requirements:
|
265
|
+
- - ">="
|
266
|
+
- !ruby/object:Gem::Version
|
267
|
+
version: '0'
|
268
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
269
|
+
requirements:
|
270
|
+
- - ">="
|
271
|
+
- !ruby/object:Gem::Version
|
272
|
+
version: '0'
|
273
|
+
requirements: []
|
274
|
+
rubyforge_project:
|
275
|
+
rubygems_version: 2.4.5.1
|
276
|
+
signing_key:
|
277
|
+
specification_version: 4
|
278
|
+
summary: Opinionated JSON-API stack to get the job done.
|
279
|
+
test_files: []
|
280
|
+
has_rdoc:
|