hal_api-rails 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +41 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/hal_api-rails.gemspec +33 -0
- data/lib/hal_api.rb +7 -0
- data/lib/hal_api/controller.rb +39 -0
- data/lib/hal_api/controller/actions.rb +116 -0
- data/lib/hal_api/controller/cache.rb +34 -0
- data/lib/hal_api/controller/exceptions.rb +43 -0
- data/lib/hal_api/controller/resources.rb +135 -0
- data/lib/hal_api/errors.rb +32 -0
- data/lib/hal_api/paged_collection.rb +77 -0
- data/lib/hal_api/rails.rb +15 -0
- data/lib/hal_api/rails/version.rb +5 -0
- data/lib/hal_api/represented_model.rb +21 -0
- data/lib/hal_api/representer.rb +20 -0
- data/lib/hal_api/representer/caches.rb +44 -0
- data/lib/hal_api/representer/caches/serialized_json.rb +9 -0
- data/lib/hal_api/representer/curies.rb +62 -0
- data/lib/hal_api/representer/embeds.rb +87 -0
- data/lib/hal_api/representer/format_keys.rb +28 -0
- data/lib/hal_api/representer/link_serialize.rb +26 -0
- data/lib/hal_api/representer/uri_methods.rb +108 -0
- metadata +215 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'roar/json/hal'
|
2
|
+
|
3
|
+
module HalApi::Errors
|
4
|
+
|
5
|
+
class ApiError < StandardError
|
6
|
+
attr_accessor :status
|
7
|
+
|
8
|
+
def initialize(message = nil, status = 500)
|
9
|
+
super(message || "API Error")
|
10
|
+
self.status = status
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class NotFound < ApiError
|
15
|
+
def initialize(message = nil)
|
16
|
+
super(message || "Resource Not Found", 404)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class UnsupportedMediaType < ApiError
|
21
|
+
def initialize(type)
|
22
|
+
super("Unsupported Media Type '#{type.inspect}'", 415)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module Representer
|
27
|
+
include Roar::JSON::HAL
|
28
|
+
|
29
|
+
property :status
|
30
|
+
property :message
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'openssl'
|
3
|
+
require 'active_model'
|
4
|
+
|
5
|
+
class HalApi::PagedCollection
|
6
|
+
extend ActiveModel::Naming
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
attr_accessor :items, :request, :options
|
10
|
+
|
11
|
+
def_delegators :items, :total_count, :prev_page, :next_page, :total_pages, :first_page?, :last_page?
|
12
|
+
alias_method :total, :total_count
|
13
|
+
|
14
|
+
def_delegators :request, :params
|
15
|
+
|
16
|
+
def to_model
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
def persisted?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(items, request=nil, options=nil)
|
25
|
+
self.items = items
|
26
|
+
self.request = request || request_stub
|
27
|
+
self.options = options || {}
|
28
|
+
self.options[:is_root_resource] = true unless (self.options[:is_root_resource] == false)
|
29
|
+
end
|
30
|
+
|
31
|
+
def cache_key
|
32
|
+
item_keys = items.inject([]) do |keys, i|
|
33
|
+
keys << i.try(:id)
|
34
|
+
keys << i.try(:updated_at).try(:utc).to_i
|
35
|
+
end
|
36
|
+
key_components = ['c', item_class.model_name.cache_key]
|
37
|
+
key_components << OpenSSL::Digest::MD5.hexdigest(item_keys.join)
|
38
|
+
ActiveSupport::Cache.expand_cache_key(key_components)
|
39
|
+
end
|
40
|
+
|
41
|
+
def request_stub
|
42
|
+
OpenStruct.new(params: {})
|
43
|
+
end
|
44
|
+
|
45
|
+
def is_root_resource
|
46
|
+
!!self.options[:is_root_resource]
|
47
|
+
end
|
48
|
+
|
49
|
+
def show_curies
|
50
|
+
is_root_resource && !options[:no_curies]
|
51
|
+
end
|
52
|
+
|
53
|
+
def item_class
|
54
|
+
options[:item_class] || self.items.first.try(:item_class) || self.items.first.class
|
55
|
+
end
|
56
|
+
|
57
|
+
def item_decorator
|
58
|
+
options[:item_decorator] || "Api::#{item_class.name}Representer".constantize
|
59
|
+
end
|
60
|
+
|
61
|
+
# url to use for the self:href, can be a string or proc
|
62
|
+
def url
|
63
|
+
options[:url]
|
64
|
+
end
|
65
|
+
|
66
|
+
# If this is an embedded collection, the parent will be set here for use in urls
|
67
|
+
def parent
|
68
|
+
rep = options[:parent]
|
69
|
+
return rep unless rep.respond_to?(:becomes)
|
70
|
+
klass = rep.class.try(:base_class)
|
71
|
+
(klass && (klass != rep.class)) ? rep.becomes(klass) : rep
|
72
|
+
end
|
73
|
+
|
74
|
+
def count
|
75
|
+
items.length
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "hal_api/rails/version"
|
2
|
+
require "hal_api"
|
3
|
+
|
4
|
+
module HalApi::Rails
|
5
|
+
require "responders"
|
6
|
+
|
7
|
+
Mime::Type.register 'application/hal+json', :hal
|
8
|
+
|
9
|
+
# roar fix for 4.1
|
10
|
+
# https://github.com/apotonick/roar-rails/issues/65
|
11
|
+
::ActionController::Renderers.add :hal do |js, options|
|
12
|
+
self.content_type ||= Mime::HAL
|
13
|
+
js.to_json
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module HalApi::RepresentedModel
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
# this concept is used in result set, perhaps doesn't belong in model itself
|
7
|
+
# consider if there is a better way to do this - decorate model instead?
|
8
|
+
attr_accessor :is_root_resource
|
9
|
+
|
10
|
+
def is_root_resource
|
11
|
+
!!@is_root_resource
|
12
|
+
end
|
13
|
+
|
14
|
+
def show_curies
|
15
|
+
is_root_resource
|
16
|
+
end
|
17
|
+
|
18
|
+
included do
|
19
|
+
extend ActiveModel::Naming unless (method(:model_name) rescue nil)
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
class HalApi::Representer < Roar::Decorator
|
4
|
+
require 'hal_api/representer/caches'
|
5
|
+
require 'hal_api/representer/curies'
|
6
|
+
require 'hal_api/representer/embeds'
|
7
|
+
require 'hal_api/representer/format_keys'
|
8
|
+
require 'hal_api/representer/link_serialize'
|
9
|
+
require 'hal_api/representer/uri_methods'
|
10
|
+
|
11
|
+
include Roar::JSON::HAL
|
12
|
+
include HalApi::Representer::FormatKeys
|
13
|
+
include HalApi::Representer::UriMethods
|
14
|
+
include HalApi::Representer::Curies
|
15
|
+
include HalApi::Representer::Embeds
|
16
|
+
include HalApi::Representer::Caches
|
17
|
+
include HalApi::Representer::LinkSerialize
|
18
|
+
self_link
|
19
|
+
profile_link
|
20
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
# expects underlying model to have filename, class, and id attributes
|
4
|
+
module HalApi::Representer::Caches
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
require 'hal_api/representer/caches/serialized_json'
|
7
|
+
|
8
|
+
# Pass in an option for the format this is going `to_`
|
9
|
+
# used in caching the final string format of the obj
|
10
|
+
# rather than the intermediary `Hash`, a modest accelerant
|
11
|
+
def to_json(options={})
|
12
|
+
options[:to_] = :json
|
13
|
+
super(options)
|
14
|
+
end
|
15
|
+
|
16
|
+
# def create_representation_with(doc, options, format)
|
17
|
+
# cache.fetch(cache_key(represented, options), cache_options) do
|
18
|
+
# response = super(doc, options, format)
|
19
|
+
# response = HalApi::Representer::Caches::SerializedJson.new(JSON.dump(response)) if (options[:to_] == :json)
|
20
|
+
# response
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
|
24
|
+
def cache_key(obj, options)
|
25
|
+
key_components = [cache_key_class_name]
|
26
|
+
key_components << (obj.try(:is_root_resource) ? 'r' : 'l')
|
27
|
+
key_components << obj
|
28
|
+
key_components << options.select{|k,v| Array(options['_keys']).include?(k.to_s)}
|
29
|
+
|
30
|
+
ActiveSupport::Cache.expand_cache_key(key_components)
|
31
|
+
end
|
32
|
+
|
33
|
+
def cache
|
34
|
+
Rails.cache
|
35
|
+
end
|
36
|
+
|
37
|
+
def cache_key_class_name
|
38
|
+
self.class.name.underscore.gsub(/_representer$/, "")
|
39
|
+
end
|
40
|
+
|
41
|
+
def cache_options
|
42
|
+
{compress: true, race_condition_ttl: 10, expires_in: 1.hour}
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# Wrapper for string that is already json
|
2
|
+
# inspired by: http://grosser.it/2013/10/16/compiled-json-for-partially-cached-json-response-precompiled-handlebar-templates/
|
3
|
+
class HalApi::Representer::Caches::SerializedJson
|
4
|
+
def initialize(s); @s = s; end
|
5
|
+
def to_json(*args); @s; end
|
6
|
+
def to_s; @s; end
|
7
|
+
|
8
|
+
undef_method :as_json
|
9
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module HalApi::Representer::Curies
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
class_eval do
|
8
|
+
class_attribute :default_curie, instance_writer: false
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
|
14
|
+
LINK_RELATIONS = %w(about alternate appendix archives author bookmark
|
15
|
+
canonical chapter collection contents copyright create-form curies current
|
16
|
+
describedby describes disclosure duplicate edit edit-form edit-media
|
17
|
+
enclosure first glossary help hosts hub icon index item last latest-version
|
18
|
+
license lrdd memento monitor monitor-group next next-archive nofollow
|
19
|
+
noreferrer original payment predecessor-version prefetch prev preview
|
20
|
+
previous prev-archive privacy-policy profile related replies search section
|
21
|
+
self service start stylesheet subsection successor-version tag
|
22
|
+
terms-of-service timegate timemap type up version-history via working-copy
|
23
|
+
working-copy-of)
|
24
|
+
|
25
|
+
def use_curie(curie)
|
26
|
+
self.default_curie = curie
|
27
|
+
end
|
28
|
+
|
29
|
+
def curies(curie, &block)
|
30
|
+
use_curie(curie) unless default_curie
|
31
|
+
link({rel: :curies, array: true}) do
|
32
|
+
if represented.try(:show_curies)
|
33
|
+
block.call
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def curify(rel, curie=default_curie)
|
39
|
+
if curie.blank? || rel.to_s =~ /:/ || LINK_RELATIONS.include?(rel.to_s)
|
40
|
+
rel
|
41
|
+
else
|
42
|
+
"#{curie}:#{rel}".to_sym
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def link(options, &block)
|
47
|
+
options = {:rel => options} unless options.is_a?(Hash)
|
48
|
+
options[:rel] = curify(options[:rel])
|
49
|
+
super(options, &block)
|
50
|
+
end
|
51
|
+
|
52
|
+
def property(name, options={})
|
53
|
+
options[:as] = curify(options[:as] || name) if options[:embedded]
|
54
|
+
super(name, options)
|
55
|
+
end
|
56
|
+
|
57
|
+
def collection(name, options={})
|
58
|
+
options[:as] = curify(options[:as] || name) if options[:embedded]
|
59
|
+
super(name, options)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
# expects underlying model to have filename, class, and id attributes
|
4
|
+
module HalApi::Representer::Embeds
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
Representable::Mapper.send(:include, Resources) if !Representable::Mapper.include?(Resources)
|
9
|
+
end
|
10
|
+
|
11
|
+
module Resources
|
12
|
+
|
13
|
+
def skip_property?(binding, options)
|
14
|
+
super(binding, options) || suppress_embed?(binding, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
# embed if zoomed
|
18
|
+
def suppress_embed?(binding, options)
|
19
|
+
name = (binding[:as] || binding.name).to_s
|
20
|
+
embedded = !!binding[:embedded]
|
21
|
+
|
22
|
+
# not embedded, return false - nothing to suppress
|
23
|
+
return false if !embedded
|
24
|
+
|
25
|
+
# check if it should be zoomed, suppress if not
|
26
|
+
!embed_zoomed?(name, binding[:zoom], options[:zoom])
|
27
|
+
end
|
28
|
+
|
29
|
+
def embed_zoomed?(name, zoom_def=nil, zoom_param=nil)
|
30
|
+
# if the embed in the representer definition has `zoom: :always` defined
|
31
|
+
# always embed it, even if it is in another embed
|
32
|
+
# (this is really meant for collections where embedded items must be included)
|
33
|
+
return true if zoom_def == :always
|
34
|
+
|
35
|
+
# passing nil explicitly overwrites defaults in signature,
|
36
|
+
# so we default to nil and fix in the method body
|
37
|
+
zoom_def = true if zoom_def.nil?
|
38
|
+
|
39
|
+
# if there is no zoom specified in the request params (options)
|
40
|
+
# then embed based on the zoom option in the representer definition
|
41
|
+
|
42
|
+
# if there is a zoom specified in the request params (options)
|
43
|
+
# then do not zoom when this name is not in the request
|
44
|
+
zoom_param.nil? ? zoom_def : zoom_param.include?(name)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Possible values for zoom option in the embed representer definition
|
49
|
+
# * false - will be zoomed only if in the root doc and in the zoom param
|
50
|
+
# * true - zoomed in root doc if no zoom_param, or if included in zoom_param
|
51
|
+
# * always - zoomed no matter what is in zoom param, and even if in embed
|
52
|
+
module ClassMethods
|
53
|
+
|
54
|
+
def embed(name, options={})
|
55
|
+
options[:embedded] = true
|
56
|
+
options[:writeable] = false
|
57
|
+
options[:if] ||= ->(_a) { id } unless options[:zoom] == :always
|
58
|
+
|
59
|
+
if options[:paged]
|
60
|
+
opts = {
|
61
|
+
no_curies: true,
|
62
|
+
item_class: options.delete(:item_class),
|
63
|
+
url: options.delete(:url),
|
64
|
+
item_decorator: options.delete(:item_decorator),
|
65
|
+
per: options.delete(:per) || Kaminari.config.default_per_page
|
66
|
+
}
|
67
|
+
options[:getter] ||= ->(*) do
|
68
|
+
# set # per page based on default, option value integer, or special :all
|
69
|
+
per = opts.delete(:per)
|
70
|
+
per = self.send(name).count if per == :all
|
71
|
+
PagedCollection.new(self.send(name).page(1).per(per), nil, opts.merge({parent: self}))
|
72
|
+
end
|
73
|
+
options[:decorator] = Api::PagedCollectionRepresenter
|
74
|
+
end
|
75
|
+
|
76
|
+
property(name, options)
|
77
|
+
end
|
78
|
+
|
79
|
+
def embeds(name, options={})
|
80
|
+
options[:embedded] = true
|
81
|
+
options[:writeable] = false
|
82
|
+
options[:if] ||= ->(_a) { id } unless options[:zoom] == :always
|
83
|
+
|
84
|
+
collection(name, options)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
# Link relation names seem to have a standard of being dasherized, as they are URIs
|
4
|
+
# property names, on the other hand should, should be camelcase, lower first
|
5
|
+
module HalApi::Representer::FormatKeys
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
|
10
|
+
def link(options, &block)
|
11
|
+
options = {:rel => options} unless options.is_a?(Hash)
|
12
|
+
options[:rel] = options[:rel].to_s.dasherize
|
13
|
+
super(options, &block)
|
14
|
+
end
|
15
|
+
|
16
|
+
def property(name, options={})
|
17
|
+
n = (options[:as] || name).to_s
|
18
|
+
options[:as] = options[:embedded] ? n.dasherize : n.camelize(:lower)
|
19
|
+
super(name, options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def collection(name, options={})
|
23
|
+
n = (options[:as] || name).to_s
|
24
|
+
options[:as] = options[:embedded] ? n.dasherize : n.camelize(:lower)
|
25
|
+
super(name, options)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module HalApi::Representer::LinkSerialize
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
|
9
|
+
def link(options, &block)
|
10
|
+
set_link_property(options)
|
11
|
+
super(options, &block)
|
12
|
+
end
|
13
|
+
|
14
|
+
def set_link_property(options)
|
15
|
+
if options.is_a?(Hash) && (options.delete(:writeable) || options[:reader])
|
16
|
+
name = options[:rel].to_s.split(':').last.split('/').last
|
17
|
+
pname = "set_#{name}_uri"
|
18
|
+
reader = options.delete(:reader) || ->(doc, _args) do
|
19
|
+
try("#{name}_id=", id_from_url(doc[pname])) if doc[pname]
|
20
|
+
end
|
21
|
+
|
22
|
+
property(pname, readable: false, reader: reader)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|