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.
@@ -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,5 @@
1
+ module HalApi
2
+ module Rails
3
+ VERSION = "0.2.0"
4
+ end
5
+ 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