publishing_platform_api_adapters 0.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.
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: []