shaf_client 0.6.1 → 1.0.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 = {})
@@ -28,14 +41,29 @@ class ShafClient
28
41
  super(payload)
29
42
  end
30
43
 
31
- %i[get put post delete patch].each do |method|
44
+ def inspect
45
+ <<~RESOURCE
46
+ Status: #{http_status}
47
+ Headers: #{headers}
48
+ #{to_s}
49
+ RESOURCE
50
+ end
51
+
52
+ %i[put post delete patch].each do |method|
32
53
  define_method(method) do |rel, payload = nil, **options|
33
54
  href = link(rel).href
34
55
  client.send(method, href, payload: payload, **options)
35
56
  end
36
57
  end
37
58
 
38
- 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)
39
67
  rel = rel.to_s
40
68
  curie_name, rel =
41
69
  if rel.include? ':'
@@ -46,7 +74,7 @@ class ShafClient
46
74
 
47
75
  curie = curie(curie_name)
48
76
  uri = curie.resolve_templated(rel: rel)
49
- client.get_doc(uri)
77
+ client.get(uri, **options)
50
78
  end
51
79
 
52
80
  def get_hal_form(rel)
@@ -72,9 +100,14 @@ class ShafClient
72
100
  delete(:delete)
73
101
  end
74
102
 
103
+ def content_type
104
+ headers['content-type']
105
+ end
106
+
75
107
  protected
76
108
 
77
109
  def <<(other)
110
+ @client = other.client
78
111
  @http_status = other.http_status.dup
79
112
  @headers = other.headers.dup
80
113
  super
@@ -83,5 +116,44 @@ class ShafClient
83
116
  private
84
117
 
85
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
86
158
  end
87
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