shaf_client 0.6.2 → 1.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.
@@ -0,0 +1,53 @@
1
+ require 'forwardable'
2
+
3
+ class ShafClient
4
+ class ContentTypeMap
5
+ extend Forwardable
6
+
7
+ def_delegators :@map, :default, :default=, :keys, :values, :each
8
+
9
+ def initialize
10
+ @map = {}
11
+ end
12
+
13
+ def [](content_type, profile = nil)
14
+ key = key_for(content_type, profile)
15
+ map.fetch(key) do
16
+ key = key_for(content_type, nil)
17
+ map[key]
18
+ end
19
+ end
20
+
21
+ def []=(content_type, profile = nil, value)
22
+ key = key_for(content_type, profile)
23
+ map[key] = value
24
+ end
25
+
26
+ def key?(content_type, profile = nil)
27
+ key = key_for(content_type, profile)
28
+ map.key? key
29
+ end
30
+
31
+ def delete(content_type, profile = nil)
32
+ key = key_for(content_type, profile)
33
+ map.delete(key)
34
+ end
35
+
36
+ def key_for(content_type, profile)
37
+ return unless content_type
38
+
39
+ key = content_type.to_s.downcase
40
+ key = strip_parameters(key)
41
+ key << "_#{profile.to_s.downcase}" if profile
42
+ key
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :map
48
+
49
+ def strip_parameters(content_type)
50
+ content_type&.sub(/;.*/, '')
51
+ end
52
+ end
53
+ end
@@ -26,5 +26,9 @@ class ShafClient
26
26
  args[:rel] &&= args[:rel].to_s.sub(/#{name}:/, '')
27
27
  super(**args)
28
28
  end
29
+
30
+ def to_h
31
+ {name: name}.merge(super)
32
+ end
29
33
  end
30
34
  end
@@ -21,7 +21,7 @@ class ShafClient
21
21
 
22
22
  %i[get put post delete patch, get_doc, reload!].each do |method|
23
23
  define_method(method) do |*_args|
24
- raise "EmptyResource: #{method} not available"
24
+ raise Error, "EmptyResource: #{method} not available"
25
25
  end
26
26
  end
27
27
  end
@@ -24,7 +24,7 @@ class ShafClient
24
24
 
25
25
  def fields
26
26
  template[:properties].map do |values|
27
- Field.new(values.transform_keys(&:to_sym))
27
+ Field.new(**values.transform_keys(&:to_sym))
28
28
  end
29
29
  end
30
30
 
@@ -0,0 +1,43 @@
1
+ class ShafClient
2
+ module HypertextCacheStrategy
3
+ AVAILABLE_CACHE_STRATEGIES = [
4
+ CACHE_STRATEGY_NO_CACHE = :no_cache,
5
+ CACHE_STRATEGY_EMBEDDED = :use_embedded,
6
+ CACHE_STRATEGY_FETCH_HEADERS = :fetch_headers
7
+ ]
8
+
9
+ class << self
10
+ def cacheable?(strategy)
11
+ [CACHE_STRATEGY_EMBEDDED, CACHE_STRATEGY_FETCH_HEADERS].include? strategy
12
+ end
13
+
14
+ def fetch_headers?(strategy)
15
+ CACHE_STRATEGY_FETCH_HEADERS == strategy
16
+ end
17
+
18
+ def default_http_status
19
+ 203
20
+ end
21
+ end
22
+
23
+ def default_hypertext_cache_strategy
24
+ @__default_hypertext_cache_strategy ||= CACHE_STRATEGY_EMBEDDED
25
+ end
26
+
27
+ def default_hypertext_cache_strategy=(strategy)
28
+ unless __valid_cache? strategy
29
+ raise Error, <<~ERR
30
+ Unsupported hypertext cache strategy: #{strategy}
31
+ Possible strategies are: #{AVAILABLE_CACHE_STRATEGIES.join(', ')}
32
+ ERR
33
+ end
34
+ @__default_hypertext_cache_strategy = value
35
+ end
36
+
37
+ private
38
+
39
+ def __valid_cache?(strategy)
40
+ AVAILABLE_CACHE_STRATEGIES.include? strategy
41
+ end
42
+ end
43
+ end
@@ -27,8 +27,8 @@ class ShafClient
27
27
  return href unless templated?
28
28
 
29
29
  href
30
- .then { |href| resolve_required(href, **args) }
31
- .then { |href| resolve_optional(href, **args) }
30
+ .yield_self { |href| resolve_required(href, **args) }
31
+ .yield_self { |href| resolve_optional(href, **args) }
32
32
  end
33
33
 
34
34
  def to_h
@@ -0,0 +1,8 @@
1
+ class ShafClient
2
+ module MimeTypes
3
+ MIME_TYPE_JSON = 'application/json'
4
+ MIME_TYPE_HAL = 'application/hal+json'
5
+ MIME_TYPE_PROBLEM_JSON = 'application/problem+json'
6
+ MIME_TYPE_ALPS_JSON = 'application/alps+json'
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ require 'shaf_client/form'
2
+ require 'shaf_client/status_codes'
3
+
4
+ class ShafClient
5
+ class ProblemJson < Resource
6
+ include StatusCodes
7
+
8
+ content_type MIME_TYPE_PROBLEM_JSON
9
+
10
+ def type
11
+ attribute(:type) { 'about:blank' }
12
+ end
13
+
14
+ def title
15
+ attribute(:title) do
16
+ next unless type == 'about:blank'
17
+
18
+ StatusCode[status] if (400..599).include? status.to_i
19
+ end
20
+ end
21
+
22
+ def status
23
+ attribute(:status) { http_status }
24
+ end
25
+
26
+ def detail
27
+ attribute(:detail)
28
+ end
29
+
30
+ def instance
31
+ attribute(:instance)
32
+ end
33
+
34
+ def to_h
35
+ attributes
36
+ end
37
+ end
38
+ end
@@ -4,21 +4,34 @@ require 'shaf_client/base_resource'
4
4
 
5
5
  class ShafClient
6
6
  class Resource < BaseResource
7
+ include MimeTypes
8
+
7
9
  attr_reader :http_status, :headers
8
10
 
9
- ResourceMapper.register("application/hal+json", self)
11
+ ResourceMapper.register(MIME_TYPE_HAL, self)
10
12
 
11
- def self.content_type(type)
12
- ResourceMapper.register(type, self)
13
+ def self.content_type(type, profile: nil)
14
+ ResourceMapper.register(type, profile, self)
13
15
  end
14
16
 
15
17
  def self.profile(name)
16
- content_type "application/hal+json;profile=#{name}"
18
+ content_type MIME_TYPE_HAL, profile: name
19
+ end
20
+
21
+ def self.build(client, payload, content_type = MIME_TYPE_HAL, status = nil, headers = {})
22
+ resource_class, extensions = ResourceMapper.for(
23
+ content_type: content_type,
24
+ headers: headers,
25
+ payload: payload,
26
+ client: client,
27
+ )
28
+ resrc = resource_class.new(client, payload, status, headers)
29
+ extensions.compact.each { |extension| resrc.extend extension }
30
+ resrc
17
31
  end
18
32
 
19
- def self.build(client, payload, status = nil, headers = {})
20
- content_type = headers.fetch('content-type', '')
21
- ResourceMapper.for(content_type).new(client, payload, status, headers)
33
+ def self.default_resource_class!
34
+ ResourceMapper.default = self
22
35
  end
23
36
 
24
37
  def initialize(client, payload, status = nil, headers = {})
@@ -36,14 +49,21 @@ class ShafClient
36
49
  RESOURCE
37
50
  end
38
51
 
39
- %i[get put post delete patch].each do |method|
52
+ %i[put post delete patch].each do |method|
40
53
  define_method(method) do |rel, payload = nil, **options|
41
54
  href = link(rel).href
42
55
  client.send(method, href, payload: payload, **options)
43
56
  end
44
57
  end
45
58
 
46
- def get_doc(rel)
59
+ def get(rel, **options)
60
+ href = link(rel).href
61
+ embedded_resource = _embedded(rel)
62
+ cached_resource = hypertext_cache_resource(href, embedded_resource, options)
63
+ cached_resource || client.get(href, **options)
64
+ end
65
+
66
+ def get_doc(rel, **options)
47
67
  rel = rel.to_s
48
68
  curie_name, rel =
49
69
  if rel.include? ':'
@@ -54,7 +74,7 @@ class ShafClient
54
74
 
55
75
  curie = curie(curie_name)
56
76
  uri = curie.resolve_templated(rel: rel)
57
- client.get_doc(uri)
77
+ client.get(uri, **options)
58
78
  end
59
79
 
60
80
  def get_hal_form(rel)
@@ -80,9 +100,14 @@ class ShafClient
80
100
  delete(:delete)
81
101
  end
82
102
 
103
+ def content_type
104
+ headers['content-type']
105
+ end
106
+
83
107
  protected
84
108
 
85
109
  def <<(other)
110
+ @client = other.client
86
111
  @http_status = other.http_status.dup
87
112
  @headers = other.headers.dup
88
113
  super
@@ -91,5 +116,44 @@ class ShafClient
91
116
  private
92
117
 
93
118
  attr_reader :client
119
+
120
+ def build_embedded_resource(payload)
121
+ self.class.build(
122
+ client,
123
+ payload,
124
+ headers['content-type'],
125
+ http_status,
126
+ headers
127
+ )
128
+ end
129
+
130
+ def hypertext_cache_strategy(options)
131
+ options.fetch(:hypertext_cache_strategy) do
132
+ ShafClient.default_hypertext_cache_strategy
133
+ end
134
+ end
135
+
136
+ def hypertext_cache?(options)
137
+ HypertextCacheStrategy.cacheable? hypertext_cache_strategy(options)
138
+ end
139
+
140
+ def hypertext_cache_resource(href, embedded_resource, options)
141
+ return unless embedded_resource
142
+
143
+ cache_strategy = hypertext_cache_strategy(options)
144
+ return unless HypertextCacheStrategy.cacheable? cache_strategy
145
+
146
+ if HypertextCacheStrategy.fetch_headers? cache_strategy
147
+ resource = client.head(href, **options)
148
+ status = resource.http_status
149
+ headers = resource.headers
150
+ embedded_resource = embedded_resource.payload
151
+ else
152
+ status = HypertextCacheStrategy.default_http_status
153
+ headers = embedded_resource.headers
154
+ end
155
+
156
+ self.class.build(client, embedded_resource, headers['content-type'], status, headers)
157
+ end
94
158
  end
95
159
  end
@@ -0,0 +1,36 @@
1
+ require 'set'
2
+
3
+ class ShafClient
4
+ module ResourceExtension
5
+ class << self
6
+ def register(extender)
7
+ extenders << extender
8
+ end
9
+
10
+ def unregister(extender)
11
+ extenders.delete(extender)
12
+ end
13
+
14
+ def for(profile, base, link_relations, client)
15
+ link_relations = remove_curies(link_relations)
16
+ extenders.map { |extender| extender.call(profile, base, link_relations, client) }
17
+ .compact
18
+ end
19
+
20
+ private
21
+
22
+ def extenders
23
+ @extenders ||= Set.new
24
+ end
25
+
26
+ def remove_curies(link_relations)
27
+ Array(link_relations).map do |rel|
28
+ rel.to_s.sub(/[^:]*:/, '').to_sym
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ require 'shaf_client/resource_extension/base'
36
+ require 'shaf_client/resource_extension/alps_http_method'
@@ -0,0 +1,67 @@
1
+ class ShafClient
2
+ module ResourceExtension
3
+ class AlpsHttpMethod < Base
4
+ class << self
5
+ def call(profile, base, link_relations, _client)
6
+ return unless profile.is_a? AlpsJson
7
+ return unless base <= Resource
8
+
9
+ link_relations = Array(link_relations).compact
10
+ descriptors = descriptors_with_http_method(profile)
11
+ descriptors.keep_if do |descriptor|
12
+ link_relations.include? identifier_for(descriptor)&.to_sym
13
+ end
14
+
15
+ extension_for(descriptors)
16
+ end
17
+
18
+ private
19
+
20
+ def descriptors_with_http_method(profile)
21
+ profile.each_descriptor.each_with_object([]) do |descriptor, descriptors|
22
+ next unless descriptor.extension(:http_method)
23
+ descriptors << descriptor
24
+ end
25
+ end
26
+
27
+ def extension_for(descriptors)
28
+ return if descriptors.empty?
29
+
30
+ Module.new.tap do |mod|
31
+ descriptors.each do |descriptor|
32
+ add_method(mod, descriptor, methods.first)
33
+ end
34
+ end
35
+ end
36
+
37
+ def add_method(mod, descriptor, method)
38
+ rel = identifier_for(descriptor)
39
+ return unless rel
40
+
41
+ ext = descriptor.extension(:http_method)
42
+ methods = Array(ext&.value)
43
+
44
+ # We only know what method to use when size is 1
45
+ return unless methods.size == 1
46
+
47
+ http_method = methods.first.downcase.to_sym
48
+ name = method_name_from(rel)
49
+
50
+ mod.define_method(name) do |payload: nil, **options|
51
+ href = link(rel).href
52
+ client.send(http_method, href, payload: payload, **options)
53
+ end
54
+ end
55
+
56
+ def method_name_from(rel)
57
+ "#{rel.to_s.downcase.tr('-', '_')}!"
58
+ end
59
+
60
+ def identifier_for(descriptor)
61
+ # Currently we only support `id` (i.e no support for descriptors with `href`)
62
+ descriptor.id
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,13 @@
1
+ class ShafClient
2
+ module ResourceExtension
3
+ class Base
4
+ def self.inherited(mod)
5
+ ResourceExtension.register(mod)
6
+ end
7
+
8
+ def self.call(*args)
9
+ raise NotImplementedError, "Class '#{self}' must respond to `call`"
10
+ end
11
+ end
12
+ end
13
+ end