hal_api-rails 0.2.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/.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
|