publishing_platform_api_adapters 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9b05ed814403c8e76bb61a31e28e271636a959b364b91790c4db66480814d726
4
+ data.tar.gz: a6c23beead962d8c2db6830e3022d7876d783a917fc6d8b468ac00fcd07fd5bb
5
+ SHA512:
6
+ metadata.gz: '06587180c50b7e88b24df754c3ace7d7d7202182d28d066736cba648f57260f46c86fa7d1ecb9e5ce5057ee00f3bc7b48f1530cf375161261ae8b621b375edd7'
7
+ data.tar.gz: 6dbd74d1d29c65369b5f605d29af19bc6404c901e55164997a70b3b4d74dfe57ad7d06d7e3477e2ae5f08600f366f8ad054902d3d54af9ae99080e3ac40cab12
data/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # Publishing Platform API Adapters
2
+ A set of API adapters to work with the Publishing Platform APIs
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,87 @@
1
+ require_relative "json_client"
2
+ require "cgi"
3
+ require "null_logger"
4
+ require "publishing_platform_location"
5
+ require_relative "list_response"
6
+
7
+ class PublishingPlatformApi::Base
8
+ class InvalidAPIURL < StandardError
9
+ end
10
+
11
+ extend Forwardable
12
+
13
+ def client
14
+ @client ||= create_client
15
+ end
16
+
17
+ def create_client
18
+ PublishingPlatformApi::JsonClient.new(options)
19
+ end
20
+
21
+ def_delegators :client,
22
+ :get_json,
23
+ :post_json,
24
+ :put_json,
25
+ :patch_json,
26
+ :delete_json,
27
+ :get_raw,
28
+ :get_raw!,
29
+ :put_multipart,
30
+ :post_multipart
31
+
32
+ attr_reader :options
33
+
34
+ class << self
35
+ attr_writer :logger
36
+ attr_accessor :default_options
37
+ end
38
+
39
+ def self.logger
40
+ @logger ||= NullLogger.instance
41
+ end
42
+
43
+ def initialize(endpoint_url, options = {})
44
+ options[:endpoint_url] = endpoint_url
45
+ raise InvalidAPIURL unless endpoint_url =~ URI::RFC3986_Parser::RFC3986_URI
46
+
47
+ base_options = { logger: PublishingPlatformApi::Base.logger }
48
+ default_options = base_options.merge(PublishingPlatformApi::Base.default_options || {})
49
+ @options = default_options.merge(options)
50
+ self.endpoint = options[:endpoint_url]
51
+ end
52
+
53
+ def url_for_slug(slug, options = {})
54
+ "#{base_url}/#{slug}.json#{query_string(options)}"
55
+ end
56
+
57
+ def get_list(url)
58
+ get_json(url) do |r|
59
+ PublishingPlatformApi::ListResponse.new(r, self)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ attr_accessor :endpoint
66
+
67
+ def query_string(params)
68
+ return "" if params.empty?
69
+
70
+ param_pairs = params.sort.map { |key, value|
71
+ case value
72
+ when Array
73
+ value.map do |v|
74
+ "#{CGI.escape("#{key}[]")}=#{CGI.escape(v.to_s)}"
75
+ end
76
+ else
77
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
78
+ end
79
+ }.flatten
80
+
81
+ "?#{param_pairs.join('&')}"
82
+ end
83
+
84
+ def uri_encode(param)
85
+ Addressable::URI.encode(param.to_s)
86
+ end
87
+ end
@@ -0,0 +1,116 @@
1
+ module PublishingPlatformApi
2
+ # Abstract error class
3
+ class BaseError < StandardError
4
+ # Give Sentry extra context about this event
5
+ # https://docs.sentry.io/clients/ruby/context/
6
+ def sentry_context
7
+ {
8
+ # Make Sentry group exceptions by type instead of message, so all
9
+ # exceptions like `PublishingPlatformApi::TimedOutException` will get grouped as one
10
+ # error and not an error per URL.
11
+ fingerprint: [self.class.name],
12
+ }
13
+ end
14
+ end
15
+
16
+ class EndpointNotFound < BaseError; end
17
+
18
+ class TimedOutException < BaseError; end
19
+
20
+ class InvalidUrl < BaseError; end
21
+
22
+ class SocketErrorException < BaseError; end
23
+
24
+ # Superclass for all 4XX and 5XX errors
25
+ class HTTPErrorResponse < BaseError
26
+ attr_accessor :code, :error_details, :http_body
27
+
28
+ def initialize(code, message = nil, error_details = nil, http_body = nil)
29
+ super(message)
30
+ @code = code
31
+ @error_details = error_details
32
+ @http_body = http_body
33
+ end
34
+ end
35
+
36
+ # Superclass & fallback for all 4XX errors
37
+ class HTTPClientError < HTTPErrorResponse; end
38
+
39
+ class HTTPIntermittentClientError < HTTPClientError; end
40
+
41
+ class HTTPNotFound < HTTPClientError; end
42
+
43
+ class HTTPGone < HTTPClientError; end
44
+
45
+ class HTTPPayloadTooLarge < HTTPClientError; end
46
+
47
+ class HTTPUnauthorized < HTTPClientError; end
48
+
49
+ class HTTPForbidden < HTTPClientError; end
50
+
51
+ class HTTPConflict < HTTPClientError; end
52
+
53
+ class HTTPUnprocessableEntity < HTTPClientError; end
54
+
55
+ class HTTPBadRequest < HTTPClientError; end
56
+
57
+ class HTTPTooManyRequests < HTTPIntermittentClientError; end
58
+
59
+ # Superclass & fallback for all 5XX errors
60
+ class HTTPServerError < HTTPErrorResponse; end
61
+
62
+ class HTTPIntermittentServerError < HTTPServerError; end
63
+
64
+ class HTTPInternalServerError < HTTPServerError; end
65
+
66
+ class HTTPBadGateway < HTTPIntermittentServerError; end
67
+
68
+ class HTTPUnavailable < HTTPIntermittentServerError; end
69
+
70
+ class HTTPGatewayTimeout < HTTPIntermittentServerError; end
71
+
72
+ module ExceptionHandling
73
+ def build_specific_http_error(error, url, details = nil)
74
+ message = "URL: #{url}\nResponse body:\n#{error.http_body}"
75
+ code = error.http_code
76
+ error_class_for_code(code).new(code, message, details, error.http_body)
77
+ end
78
+
79
+ def error_class_for_code(code)
80
+ case code
81
+ when 400
82
+ PublishingPlatformApi::HTTPBadRequest
83
+ when 401
84
+ PublishingPlatformApi::HTTPUnauthorized
85
+ when 403
86
+ PublishingPlatformApi::HTTPForbidden
87
+ when 404
88
+ PublishingPlatformApi::HTTPNotFound
89
+ when 409
90
+ PublishingPlatformApi::HTTPConflict
91
+ when 410
92
+ PublishingPlatformApi::HTTPGone
93
+ when 413
94
+ PublishingPlatformApi::HTTPPayloadTooLarge
95
+ when 422
96
+ PublishingPlatformApi::HTTPUnprocessableEntity
97
+ when 429
98
+ PublishingPlatformApi::HTTPTooManyRequests
99
+ when (400..499)
100
+ PublishingPlatformApi::HTTPClientError
101
+ when 500
102
+ PublishingPlatformApi::HTTPInternalServerError
103
+ when 502
104
+ PublishingPlatformApi::HTTPBadGateway
105
+ when 503
106
+ PublishingPlatformApi::HTTPUnavailable
107
+ when 504
108
+ PublishingPlatformApi::HTTPGatewayTimeout
109
+ when (500..599)
110
+ PublishingPlatformApi::HTTPServerError
111
+ else
112
+ PublishingPlatformApi::HTTPErrorResponse
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,201 @@
1
+ require_relative "response"
2
+ require_relative "exceptions"
3
+ require_relative "version"
4
+ require_relative "publishing_platform_headers"
5
+ require "rest-client"
6
+ require "null_logger"
7
+
8
+ module PublishingPlatformApi
9
+ class JsonClient
10
+ include PublishingPlatformApi::ExceptionHandling
11
+
12
+ attr_accessor :logger, :options
13
+
14
+ def initialize(options = {})
15
+ if options[:disable_timeout] || options[:timeout].to_i.negative?
16
+ raise "It is no longer possible to disable the timeout."
17
+ end
18
+
19
+ @logger = options[:logger] || NullLogger.instance
20
+ @options = options
21
+ end
22
+
23
+ def self.default_request_headers
24
+ {
25
+ "Accept" => "application/json",
26
+ # PUBLISHING_PLATFORM_APP_NAME is set for all apps
27
+ "User-Agent" => "publishing_platform_api_adapters/#{PublishingPlatformApi::VERSION} (#{ENV['PUBLISHING_PLATFORM_APP_NAME']})",
28
+ }
29
+ end
30
+
31
+ def self.default_request_with_json_body_headers
32
+ default_request_headers.merge(json_body_headers)
33
+ end
34
+
35
+ def self.json_body_headers
36
+ {
37
+ "Content-Type" => "application/json",
38
+ }
39
+ end
40
+
41
+ DEFAULT_TIMEOUT_IN_SECONDS = 4
42
+
43
+ def get_raw!(url)
44
+ do_raw_request(:get, url)
45
+ end
46
+
47
+ def get_raw(url)
48
+ get_raw!(url)
49
+ end
50
+
51
+ def get_json(url, additional_headers = {}, &create_response)
52
+ do_json_request(:get, url, nil, additional_headers, &create_response)
53
+ end
54
+
55
+ def post_json(url, params = {}, additional_headers = {})
56
+ do_json_request(:post, url, params, additional_headers)
57
+ end
58
+
59
+ def put_json(url, params, additional_headers = {})
60
+ do_json_request(:put, url, params, additional_headers)
61
+ end
62
+
63
+ def patch_json(url, params, additional_headers = {})
64
+ do_json_request(:patch, url, params, additional_headers)
65
+ end
66
+
67
+ def delete_json(url, params = {}, additional_headers = {})
68
+ do_json_request(:delete, url, params, additional_headers)
69
+ end
70
+
71
+ def post_multipart(url, params)
72
+ r = do_raw_request(:post, url, params.merge(multipart: true))
73
+ Response.new(r)
74
+ end
75
+
76
+ def put_multipart(url, params)
77
+ r = do_raw_request(:put, url, params.merge(multipart: true))
78
+ Response.new(r)
79
+ end
80
+
81
+ private
82
+
83
+ def do_raw_request(method, url, params = nil)
84
+ do_request(method, url, params)
85
+ rescue RestClient::Exception => e
86
+ raise build_specific_http_error(e, url, nil)
87
+ end
88
+
89
+ # method: the symbolic name of the method to use, e.g. :get, :post
90
+ # url: the request URL
91
+ # params: the data to send (JSON-serialised) in the request body
92
+ # additional_headers: headers to set on the request (in addition to the default ones)
93
+ # create_response: optional block to instantiate a custom response object
94
+ # from the Net::HTTPResponse
95
+ def do_json_request(method, url, params = nil, additional_headers = {}, &create_response)
96
+ begin
97
+ if params
98
+ additional_headers.merge!(self.class.json_body_headers)
99
+ end
100
+ response = do_request(method, url, (params.to_json if params), additional_headers)
101
+ rescue RestClient::Exception => e
102
+ # Attempt to parse the body as JSON if possible
103
+ error_details = begin
104
+ e.http_body ? JSON.parse(e.http_body) : nil
105
+ rescue JSON::ParserError
106
+ nil
107
+ end
108
+ raise build_specific_http_error(e, url, error_details)
109
+ end
110
+
111
+ # If no custom response is given, just instantiate Response
112
+ create_response ||= proc { |r| Response.new(r) }
113
+ create_response.call(response)
114
+ end
115
+
116
+ # Take a hash of parameters for Request#execute; return a hash of
117
+ # parameters with authentication information included
118
+ def with_auth_options(method_params)
119
+ if @options[:bearer_token]
120
+ headers = method_params[:headers] || {}
121
+ method_params.merge(headers: headers.merge(
122
+ "Authorization" => "Bearer #{@options[:bearer_token]}",
123
+ ))
124
+ elsif @options[:basic_auth]
125
+ method_params.merge(
126
+ user: @options[:basic_auth][:user],
127
+ password: @options[:basic_auth][:password],
128
+ )
129
+ else
130
+ method_params
131
+ end
132
+ end
133
+
134
+ # Take a hash of parameters for Request#execute; return a hash of
135
+ # parameters with timeouts included
136
+ def with_timeout(method_params)
137
+ method_params.merge(
138
+ timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS,
139
+ open_timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS,
140
+ )
141
+ end
142
+
143
+ def with_headers(method_params, default_headers, additional_headers)
144
+ method_params.merge(
145
+ headers: default_headers
146
+ .merge(method_params[:headers] || {})
147
+ .merge(PublishingPlatformApi::PublishingPlatformHeaders.headers)
148
+ .merge(additional_headers),
149
+ )
150
+ end
151
+
152
+ def with_ssl_options(method_params)
153
+ method_params.merge(
154
+ # This is the default value anyway, but we should probably be explicit
155
+ verify_ssl: OpenSSL::SSL::VERIFY_NONE,
156
+ )
157
+ end
158
+
159
+ def do_request(method, url, params = nil, additional_headers = {})
160
+ loggable = { request_uri: url, start_time: Time.now.to_f, publishing_platform_request_id: PublishingPlatformApi::PublishingPlatformHeaders.headers[:publishing_platform_request_id] }.compact
161
+ start_logging = loggable.merge(action: "start")
162
+ logger.debug start_logging.to_json
163
+
164
+ method_params = {
165
+ method:,
166
+ url:,
167
+ }
168
+
169
+ method_params[:payload] = params
170
+ method_params = with_timeout(method_params)
171
+ method_params = with_headers(method_params, self.class.default_request_headers, additional_headers)
172
+ method_params = with_auth_options(method_params)
173
+ if URI.parse(url).is_a? URI::HTTPS
174
+ method_params = with_ssl_options(method_params)
175
+ end
176
+
177
+ ::RestClient::Request.execute(method_params)
178
+ rescue Errno::ECONNREFUSED => e
179
+ logger.error loggable.merge(status: "refused", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
180
+ raise PublishingPlatformApi::EndpointNotFound, "Could not connect to #{url}"
181
+ rescue RestClient::Exceptions::Timeout => e
182
+ logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
183
+ raise PublishingPlatformApi::TimedOutException, e.message
184
+ rescue URI::InvalidURIError => e
185
+ logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
186
+ raise PublishingPlatformApi::InvalidUrl, e.message
187
+ rescue RestClient::Exception => e
188
+ # Log the error here, since we have access to loggable, but raise the
189
+ # exception up to the calling method to deal with
190
+ loggable.merge!(status: e.http_code, end_time: Time.now.to_f, body: e.http_body)
191
+ logger.warn loggable.to_json
192
+ raise
193
+ rescue Errno::ECONNRESET => e
194
+ logger.error loggable.merge(status: "connection_reset", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
195
+ raise PublishingPlatformApi::TimedOutException, e.message
196
+ rescue SocketError => e
197
+ logger.error loggable.merge(status: "socket_error", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
198
+ raise PublishingPlatformApi::SocketErrorException, e.message
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,89 @@
1
+ require "json"
2
+ require "publishing_platform_api/response"
3
+ require "link_header"
4
+
5
+ module PublishingPlatformApi
6
+ # Response class for lists of multiple items.
7
+ #
8
+ # This expects responses to be in a common format, with the list of results
9
+ # contained under the `results` key. The response may also have previous and
10
+ # subsequent pages, indicated by entries in the response's `Link` header.
11
+ class ListResponse < Response
12
+ # The ListResponse is instantiated with a reference back to the API client,
13
+ # so it can make requests for the subsequent pages
14
+ def initialize(response, api_client, options = {})
15
+ super(response, options)
16
+ @api_client = api_client
17
+ end
18
+
19
+ # Pass calls to `self.each` to the `results` sub-object, so we can iterate
20
+ # over the response directly
21
+ def_delegators :results, :each, :to_ary
22
+
23
+ def results
24
+ to_hash["results"]
25
+ end
26
+
27
+ def has_next_page?
28
+ !page_link("next").nil?
29
+ end
30
+
31
+ def next_page
32
+ # This shouldn't be a performance problem, since the cache will generally
33
+ # avoid us making multiple requests for the same page, but we shouldn't
34
+ # allow the data to change once it's already been loaded, so long as we
35
+ # retain a reference to any one page in the sequence
36
+ @next_page ||= if has_next_page?
37
+ @api_client.get_list page_link("next").href
38
+ end
39
+ end
40
+
41
+ def has_previous_page?
42
+ !page_link("previous").nil?
43
+ end
44
+
45
+ def previous_page
46
+ # See the note in `next_page` for why this is memoised
47
+ @previous_page ||= if has_previous_page?
48
+ @api_client.get_list(page_link("previous").href)
49
+ end
50
+ end
51
+
52
+ # Transparently get all results across all pages. Compare this with #each
53
+ # or #results which only iterate over the current page.
54
+ #
55
+ # Example:
56
+ #
57
+ # list_response.with_subsequent_pages.each do |result|
58
+ # ...
59
+ # end
60
+ #
61
+ # or:
62
+ #
63
+ # list_response.with_subsequent_pages.count
64
+ #
65
+ # Pages of results are fetched on demand. When iterating, that means
66
+ # fetching pages as results from the current page are exhausted. If you
67
+ # invoke a method such as #count, this method will fetch all pages at that
68
+ # point. Note that the responses are stored so subsequent pages will not be
69
+ # loaded multiple times.
70
+ def with_subsequent_pages
71
+ Enumerator.new do |yielder|
72
+ each { |i| yielder << i }
73
+ if has_next_page?
74
+ next_page.with_subsequent_pages.each { |i| yielder << i }
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def link_header
82
+ @link_header ||= LinkHeader.parse @http_response.headers[:link]
83
+ end
84
+
85
+ def page_link(rel)
86
+ link_header.find_link(["rel", rel])
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "../publishing_platform_headers"
2
+
3
+ module PublishingPlatformApi
4
+ class PublishingPlatformHeaderSniffer
5
+ def initialize(app, header_name)
6
+ @app = app
7
+ @header_name = header_name
8
+ end
9
+
10
+ def call(env)
11
+ PublishingPlatformApi::PublishingPlatformHeaders.set_header(readable_name, env[@header_name])
12
+ @app.call(env)
13
+ end
14
+
15
+ private
16
+
17
+ def readable_name
18
+ @header_name.sub(/^HTTP_/, "").downcase.to_sym
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,222 @@
1
+ require_relative "base"
2
+ require_relative "exceptions"
3
+
4
+ # Adapter for the Publishing API.
5
+ #
6
+ # @api documented
7
+ class PublishingPlatformApi::PublishingApi < PublishingPlatformApi::Base
8
+ class NoLiveVersion < PublishingPlatformApi::BaseError; end
9
+
10
+ # Put a content item
11
+ #
12
+ # @param content_id [UUID]
13
+ # @param payload [Hash] A valid content item
14
+ def put_content(content_id, payload)
15
+ put_json(content_url(content_id), payload)
16
+ end
17
+
18
+ # Return a content item
19
+ #
20
+ # Raises exception if the item doesn't exist.
21
+ #
22
+ # @param content_id [UUID]
23
+ # @param params [Hash]
24
+ #
25
+ # @return [PublishingPlatformApi::Response] a content item
26
+ #
27
+ # @raise [HTTPNotFound] when the content item is not found
28
+ def get_content(content_id, params = {})
29
+ get_json(content_url(content_id, params))
30
+ end
31
+
32
+ # Publish a content item
33
+ #
34
+ # The publishing-api will "publish" a draft item, so that it will be visible
35
+ # on the public site.
36
+ #
37
+ # @param content_id [UUID]
38
+ # @param update_type [String] Either 'major', 'minor' or 'republish'
39
+ # @param options [Hash]
40
+ def publish(content_id, update_type = nil, options = {})
41
+ params = {
42
+ update_type:,
43
+ }
44
+
45
+ optional_keys = %i[previous_version]
46
+
47
+ params = merge_optional_keys(params, options, optional_keys)
48
+
49
+ post_json(publish_url(content_id), params)
50
+ end
51
+
52
+ # Republish a content item
53
+ #
54
+ # The publishing-api will "republish" a live edition. This can be used to remove an unpublishing or to
55
+ # re-send a published edition downstream
56
+ #
57
+ # @param content_id [UUID]
58
+ # @param options [Hash]
59
+ def republish(content_id, options = {})
60
+ optional_keys = %i[previous_version]
61
+
62
+ params = merge_optional_keys({}, options, optional_keys)
63
+
64
+ post_json(republish_url(content_id), params)
65
+ end
66
+
67
+ # Unpublish a content item
68
+ #
69
+ # The publishing API will "unpublish" a live item, to remove it from the public
70
+ # site, or update an existing unpublishing.
71
+ #
72
+ # @param content_id [UUID]
73
+ # @param type [String] Either 'withdrawal', 'gone' or 'redirect'.
74
+ # @param explanation [String] (optional) Text to show on the page.
75
+ # @param alternative_path [String] (optional) Alternative path to show on the page or redirect to.
76
+ # @param discard_drafts [Boolean] (optional) Whether to discard drafts on that item. Defaults to false.
77
+ # @param previous_version [Integer] (optional) A lock version number for optimistic locking.
78
+ # @param unpublished_at [Time] (optional) The time the content was withdrawn. Ignored for types other than withdrawn
79
+ # @param redirects [Array] (optional) Required if no alternative_path is given. An array of redirect values, ie: { path:, type:, destination: }
80
+ def unpublish(content_id, type:, explanation: nil, alternative_path: nil, discard_drafts: false, allow_draft: false, previous_version: nil, unpublished_at: nil, redirects: nil)
81
+ params = {
82
+ type:,
83
+ }
84
+
85
+ params[:explanation] = explanation if explanation
86
+ params[:alternative_path] = alternative_path if alternative_path
87
+ params[:previous_version] = previous_version if previous_version
88
+ params[:discard_drafts] = discard_drafts if discard_drafts
89
+ params[:allow_draft] = allow_draft if allow_draft
90
+ params[:unpublished_at] = unpublished_at.utc.iso8601 if unpublished_at
91
+ params[:redirects] = redirects if redirects
92
+
93
+ post_json(unpublish_url(content_id), params)
94
+ end
95
+
96
+ # Discard a draft
97
+ #
98
+ # Deletes the draft content item.
99
+ #
100
+ # @param options [Hash]
101
+ # @option options [Integer] previous_version used to ensure the request is discarding the latest lock version of the draft
102
+ def discard_draft(content_id, options = {})
103
+ optional_keys = %i[previous_version]
104
+
105
+ params = merge_optional_keys({}, options, optional_keys)
106
+
107
+ post_json(discard_url(content_id), params)
108
+ end
109
+
110
+ # Patch the links of a content item
111
+ #
112
+ # @param content_id [UUID]
113
+ # @param params [Hash]
114
+ # @option params [Hash] links A "links hash"
115
+ # @option params [Integer] previous_version The previous version (returned by `get_links`). If this version is not the current version, the publishing-api will reject the change and return 409 Conflict. (optional)
116
+ # @example
117
+ #
118
+ # publishing_api.patch_links(
119
+ # '86963c13-1f57-4005-b119-e7cf3cb92ecf',
120
+ # links: {
121
+ # topics: ['d6e1527d-d0c0-40d5-9603-b9f3e6866b8a'],
122
+ # mainstream_browse_pages: ['d6e1527d-d0c0-40d5-9603-b9f3e6866b8a'],
123
+ # },
124
+ # previous_version: 10,
125
+ # bulk_publishing: true
126
+ # )
127
+ #
128
+ def patch_links(content_id, params)
129
+ payload = {
130
+ links: params.fetch(:links),
131
+ }
132
+
133
+ payload = merge_optional_keys(payload, params, %i[previous_version bulk_publishing])
134
+
135
+ patch_json(links_url(content_id), payload)
136
+ end
137
+
138
+ # Get a list of content items from the Publishing API.
139
+ #
140
+ # The only required key in the params hash is `document_type`. These will be used to filter down the content items being returned by the API. Other allowed options can be seen from the link below.
141
+ #
142
+ # @param params [Hash] At minimum, this hash has to include the `document_type` of the content items we wish to see. All other optional keys are documented above.
143
+ #
144
+ # @example
145
+ #
146
+ # publishing_api.get_content_items(
147
+ # document_type: 'taxon',
148
+ # q: 'Driving',
149
+ # page: 1,
150
+ # per_page: 50,
151
+ # publishing_app: 'content-tagger',
152
+ # fields: ['title', 'description', 'public_updated_at'],
153
+ # order: '-public_updated_at'
154
+ # )
155
+ def get_content_items(params)
156
+ query = query_string(params)
157
+ get_json("#{endpoint}/content#{query}")
158
+ end
159
+
160
+ # Reserves a path for a publishing application
161
+ #
162
+ # Returns success or failure only.
163
+ #
164
+ # @param payload [Hash]
165
+ # @option payload [Hash] publishing_app The publishing application, like `content-tagger`
166
+ def put_path(base_path, payload)
167
+ url = "#{endpoint}/paths#{base_path}"
168
+ put_json(url, payload)
169
+ end
170
+
171
+ def unreserve_path(base_path, publishing_app)
172
+ payload = { publishing_app: }
173
+ delete_json(unreserve_url(base_path), payload)
174
+ end
175
+
176
+ private
177
+
178
+ def content_url(content_id, params = {})
179
+ validate_content_id(content_id)
180
+ query = query_string(params)
181
+ "#{endpoint}/content/#{content_id}#{query}"
182
+ end
183
+
184
+ def links_url(content_id)
185
+ validate_content_id(content_id)
186
+ "#{endpoint}/links/#{content_id}"
187
+ end
188
+
189
+ def publish_url(content_id)
190
+ validate_content_id(content_id)
191
+ "#{endpoint}/content/#{content_id}/publish"
192
+ end
193
+
194
+ def republish_url(content_id)
195
+ validate_content_id(content_id)
196
+ "#{endpoint}/content/#{content_id}/republish"
197
+ end
198
+
199
+ def unpublish_url(content_id)
200
+ validate_content_id(content_id)
201
+ "#{endpoint}/content/#{content_id}/unpublish"
202
+ end
203
+
204
+ def discard_url(content_id)
205
+ validate_content_id(content_id)
206
+ "#{endpoint}/content/#{content_id}/discard-draft"
207
+ end
208
+
209
+ def unreserve_url(base_path)
210
+ "#{endpoint}/paths#{base_path}"
211
+ end
212
+
213
+ def merge_optional_keys(params, options, optional_keys)
214
+ optional_keys.each_with_object(params) do |optional_key, hash|
215
+ hash.merge!(optional_key => options[optional_key]) if options[optional_key]
216
+ end
217
+ end
218
+
219
+ def validate_content_id(content_id)
220
+ raise ArgumentError, "content_id cannot be nil" unless content_id
221
+ end
222
+ end
@@ -0,0 +1,23 @@
1
+ module PublishingPlatformApi
2
+ class PublishingPlatformHeaders
3
+ class << self
4
+ def set_header(header_name, value)
5
+ header_data[header_name] = value
6
+ end
7
+
8
+ def headers
9
+ header_data.reject { |_k, v| v.nil? || v.empty? }
10
+ end
11
+
12
+ def clear_headers
13
+ Thread.current[:headers] = {}
14
+ end
15
+
16
+ private
17
+
18
+ def header_data
19
+ Thread.current[:headers] ||= {}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "middleware/publishing_platform_header_sniffer"
2
+
3
+ module PublishingPlatformApi
4
+ class Railtie < Rails::Railtie
5
+ initializer "publishing_platform_api.initialize_publishing_platform_request_id_sniffer" do |app|
6
+ Rails.logger.debug "Using middleware PublishingPlatformApi::PublishingPlatformHeaderSniffer to sniff for PublishingPlatform-Request-Id header"
7
+ app.middleware.use PublishingPlatformApi::PublishingPlatformHeaderSniffer, "HTTP_PUBLISHING_PLATFORM_REQUEST_ID"
8
+ end
9
+
10
+ initializer "publishing_platform_api.initialize_publishing_platform_original_url_sniffer" do |app|
11
+ Rails.logger.debug "Using middleware PublishingPlatformApi::PublishingPlatformHeaderSniffer to sniff for PublishingPlatform-Original-Url header"
12
+ app.middleware.use PublishingPlatformApi::PublishingPlatformHeaderSniffer, "HTTP_PUBLISHING_PLATFORM_ORIGINAL_URL"
13
+ end
14
+
15
+ initializer "publishing_platform_api.initialize_publishing_platform_authenticated_user_sniffer" do |app|
16
+ Rails.logger.debug "Using middleware PublishingPlatformApi::PublishingPlatformHeaderSniffer to sniff for X-PublishingPlatform-Authenticated-User header"
17
+ app.middleware.use PublishingPlatformApi::PublishingPlatformHeaderSniffer, "HTTP_X_PUBLISHING_PLATFORM_AUTHENTICATED_USER"
18
+ end
19
+
20
+ initializer "publishing_platform_api.initialize_publishing_platform_authenticated_user_organisation_sniffer" do |app|
21
+ Rails.logger.debug "Using middleware PublishingPlatformApi::PublishingPlatformHeaderSniffer to sniff for X-PublishingPlatform-Authenticated-User-Organisation header"
22
+ app.middleware.use PublishingPlatformApi::PublishingPlatformHeaderSniffer, "HTTP_X_PUBLISHING_PLATFORM_AUTHENTICATED_USER_ORGANISATION"
23
+ end
24
+
25
+ initializer "publishing_platform_api.initialize_publishing_platform_content_id_sniffer" do |app|
26
+ Rails.logger.debug "Using middleware PublishingPlatformApi::PublishingPlatformHeaderSniffer to sniff for PublishingPlatform-Auth-Bypass-Id header"
27
+ app.middleware.use PublishingPlatformApi::PublishingPlatformHeaderSniffer, "HTTP_PUBLISHING_PLATFORM_AUTH_BYPASS_ID"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,183 @@
1
+ require "json"
2
+ require "forwardable"
3
+
4
+ module PublishingPlatformApi
5
+ # This wraps an HTTP response with a JSON body.
6
+ #
7
+ # Responses can be configured to use relative URLs for `web_url` properties.
8
+ # API endpoints should return absolute URLs so that they make sense outside of the
9
+ # Publishing Platform context. However on internal systems we want to present relative URLs.
10
+ # By specifying a base URI, this will convert all matching web_urls into relative URLs
11
+ # This is useful on non-canonical frontends, such as those in staging environments.
12
+ #
13
+ # Example:
14
+ #
15
+ # r = Response.new(response, web_urls_relative_to: "https://www.publishing-platform.co.uk")
16
+ # r['results'][0]['web_url']
17
+ # => "/bank-holidays"
18
+ class Response
19
+ extend Forwardable
20
+ include Enumerable
21
+
22
+ class CacheControl < Hash
23
+ PATTERN = /([-a-z]+)(?:\s*=\s*([^,\s]+))?,?+/i
24
+
25
+ def initialize(value = nil)
26
+ super()
27
+ parse(value)
28
+ end
29
+
30
+ def public?
31
+ self["public"]
32
+ end
33
+
34
+ def private?
35
+ self["private"]
36
+ end
37
+
38
+ def no_cache?
39
+ self["no-cache"]
40
+ end
41
+
42
+ def no_store?
43
+ self["no-store"]
44
+ end
45
+
46
+ def must_revalidate?
47
+ self["must-revalidate"]
48
+ end
49
+
50
+ def proxy_revalidate?
51
+ self["proxy-revalidate"]
52
+ end
53
+
54
+ def max_age
55
+ self["max-age"].to_i if key?("max-age")
56
+ end
57
+
58
+ def reverse_max_age
59
+ self["r-maxage"].to_i if key?("r-maxage")
60
+ end
61
+ alias_method :r_maxage, :reverse_max_age
62
+
63
+ def shared_max_age
64
+ self["s-maxage"].to_i if key?("r-maxage")
65
+ end
66
+ alias_method :s_maxage, :shared_max_age
67
+
68
+ def to_s
69
+ directives = []
70
+ values = []
71
+
72
+ each do |key, value|
73
+ if value == true
74
+ directives << key
75
+ elsif value
76
+ values << "#{key}=#{value}"
77
+ end
78
+ end
79
+
80
+ (directives.sort + values.sort).join(", ")
81
+ end
82
+
83
+ private
84
+
85
+ def parse(header)
86
+ return if header.nil? || header.empty?
87
+
88
+ header.scan(PATTERN).each do |name, value|
89
+ self[name.downcase] = value || true
90
+ end
91
+ end
92
+ end
93
+
94
+ def_delegators :to_hash, :[], :"<=>", :each, :dig
95
+
96
+ def initialize(http_response, options = {})
97
+ @http_response = http_response
98
+ @web_urls_relative_to = options[:web_urls_relative_to] ? URI.parse(options[:web_urls_relative_to]) : nil
99
+ end
100
+
101
+ def raw_response_body
102
+ @http_response.body
103
+ end
104
+
105
+ def code
106
+ # Return an integer code for consistency with HTTPErrorResponse
107
+ @http_response.code
108
+ end
109
+
110
+ def headers
111
+ @http_response.headers
112
+ end
113
+
114
+ def expires_at
115
+ if headers[:date] && cache_control.max_age
116
+ response_date = Time.parse(headers[:date])
117
+ response_date + cache_control.max_age
118
+ elsif headers[:expires]
119
+ Time.parse(headers[:expires])
120
+ end
121
+ end
122
+
123
+ def expires_in
124
+ return unless headers[:date]
125
+
126
+ age = Time.now.utc - Time.parse(headers[:date])
127
+
128
+ if cache_control.max_age
129
+ cache_control.max_age - age.to_i
130
+ elsif headers[:expires]
131
+ Time.parse(headers[:expires]).to_i - Time.now.utc.to_i
132
+ end
133
+ end
134
+
135
+ def cache_control
136
+ @cache_control ||= CacheControl.new(headers[:cache_control])
137
+ end
138
+
139
+ def to_hash
140
+ parsed_content
141
+ end
142
+
143
+ def parsed_content
144
+ @parsed_content ||= transform_parsed(JSON.parse(@http_response.body))
145
+ end
146
+
147
+ def present?
148
+ true
149
+ end
150
+
151
+ def blank?
152
+ false
153
+ end
154
+
155
+ private
156
+
157
+ def transform_parsed(value)
158
+ return value if @web_urls_relative_to.nil?
159
+
160
+ case value
161
+ when Hash
162
+ Hash[value.map do |k, v|
163
+ # NOTE: Don't bother transforming if the value is nil
164
+ if k == "web_url" && v
165
+ # Use relative URLs to route when the web_url value is on the
166
+ # same domain as the site root. Note that we can't just use the
167
+ # `route_to` method, as this would give us technically correct
168
+ # but potentially confusing `//host/path` URLs for URLs with the
169
+ # same scheme but different hosts.
170
+ relative_url = @web_urls_relative_to.route_to(v)
171
+ [k, relative_url.host ? v : relative_url.to_s]
172
+ else
173
+ [k, transform_parsed(v)]
174
+ end
175
+ end]
176
+ when Array
177
+ value.map { |v| transform_parsed(v) }
178
+ else
179
+ value
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PublishingPlatformApi
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,20 @@
1
+ require "addressable"
2
+ require "publishing_platform_location"
3
+ require "time"
4
+ require "publishing_platform_api/publishing_api"
5
+
6
+ # @api documented
7
+ module PublishingPlatformApi
8
+ # Creates a PublishingPlatformApi::PublishingApi adapter
9
+ #
10
+ # This will set a bearer token if a PUBLISHING_API_BEARER_TOKEN environment
11
+ # variable is set
12
+ #
13
+ # @return [PublishingPlatformApi::PublishingApi]
14
+ def self.publishing_api(options = {})
15
+ PublishingPlatformApi::PublishingApi.new(
16
+ PublishingPlatformLocation.find("publishing-api"),
17
+ { bearer_token: ENV["PUBLISHING_API_BEARER_TOKEN"] }.merge(options),
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ require "publishing_platform_api/railtie" if defined?(Rails)
2
+ require "publishing_platform_api/exceptions"
3
+ require "publishing_platform_api"
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: publishing_platform_api_adapters
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Publishing Platform
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-07-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: addressable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: link_header
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: null_logger
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: publishing_platform_location
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rest-client
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: publishing_platform_rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Adapters to work with Publishing Platform APIs
98
+ email:
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - README.md
104
+ - Rakefile
105
+ - lib/publishing_platform_api.rb
106
+ - lib/publishing_platform_api/base.rb
107
+ - lib/publishing_platform_api/exceptions.rb
108
+ - lib/publishing_platform_api/json_client.rb
109
+ - lib/publishing_platform_api/list_response.rb
110
+ - lib/publishing_platform_api/middleware/publishing_platform_header_sniffer.rb
111
+ - lib/publishing_platform_api/publishing_api.rb
112
+ - lib/publishing_platform_api/publishing_platform_headers.rb
113
+ - lib/publishing_platform_api/railtie.rb
114
+ - lib/publishing_platform_api/response.rb
115
+ - lib/publishing_platform_api/version.rb
116
+ - lib/publishing_platform_api_adapters.rb
117
+ homepage:
118
+ licenses:
119
+ - MIT
120
+ metadata: {}
121
+ post_install_message:
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '3.0'
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubygems_version: 3.3.7
137
+ signing_key:
138
+ specification_version: 4
139
+ summary: Adapters to work with Publishing Platform APIs
140
+ test_files: []