govuk_content_item_loader 0.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 365987707cc39e8b636dfa88723bf0dd4c94a927b10188e72d2cc1a6db083abf
4
- data.tar.gz: 32de377938b8a4c3f7c5ed0ef369f4d18e67a807defb745ef1bf2d348db08266
3
+ metadata.gz: 947921788377a6a5dd65917c2f546d8baaabbab0183a9673df551a064b422457
4
+ data.tar.gz: 9aecaf319b535faa6172cf652cd195e89dadc0dd92858db2c25b9737309b9e48
5
5
  SHA512:
6
- metadata.gz: '068e7bdf5420dac1daaebb68ceef011475c4ddd94b2204793c0e650909d4ae11c6606c8118d31980241e4fffcfdb2002a607cb3cb88ec8fcbced97ad580d8eb8'
7
- data.tar.gz: 695b64ce8e808861f42d95fa0e1516b326257dc0518dba887677e6619283d01a9dcdfcb0d59093b21b99a22613a6ac7b5a12c60c37c0ebbb4a8a52972d7cee96
6
+ metadata.gz: 6f5f426102604ffce580a33b0fead1eb99d623f4500cd308669bf95b4ab49d9239e7d4dab37917ce32a513ec28cc3a8682c09833aca0136bff5cee5b7a443c0a
7
+ data.tar.gz: ee6e8a19ff172558e8aaa0a86c80c189f41213f09cc1715e00e18b2e0be62d32d7e40a815ec745880c197f97135c7d622b57725050a5ad2120989fe334bb9ebf
@@ -6,7 +6,5 @@ on:
6
6
  jobs:
7
7
  autorelease:
8
8
  uses: alphagov/govuk-infrastructure/.github/workflows/autorelease-rubygem.yml@main
9
- with:
10
- gem_name: content_block_tools
11
9
  secrets:
12
10
  GH_TOKEN: ${{ secrets.GOVUK_CI_GITHUB_API_TOKEN }}
@@ -38,7 +38,7 @@ jobs:
38
38
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
39
39
  with:
40
40
  ref: ${{ inputs.ref || github.ref }}
41
- - uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
41
+ - uses: ruby/setup-ruby@6ca151fd1bfcfd6fe0c4eb6837eb0584d0134a0c # v1.290.0
42
42
  with:
43
43
  ruby-version: ${{ matrix.ruby }}
44
44
  bundler-cache: true
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ - Add Content Store fallback for failed Graphql requests
6
+
7
+ ## 1.0.0
8
+
9
+ - Add GraphQL traffic rates initializer.
10
+ - Add request-level conditional content item loader for GraphQL traffic routing with `load` and `can_load_from_graphql?` methods.
11
+
3
12
  ## 0.1.0
4
13
 
5
14
  Initial release
data/README.md CHANGED
@@ -2,6 +2,90 @@
2
2
 
3
3
  Ruby gem that standardises how GOV.UK frontend apps load content items. It provides configurable GraphQL traffic rates per content schema and a request-level loader that routes requests to the Publishing API via GraphQL or falls back to the Content Store ensuring consistent traffic management across applications.
4
4
 
5
+ ## Installation
6
+
7
+ Install the gem
8
+
9
+ `gem install govuk_content_item_loader`
10
+
11
+ or add it to your Gemfile
12
+
13
+ `gem "govuk_content_item_loader"`
14
+
15
+ ## GraphQL traffic rates
16
+
17
+ Provides a canonical way for frontend apps to configure GraphQL traffic rates for relevant content schemas. It sets the following application configuration:
18
+
19
+ * `Rails.application.config.graphql_allowed_schemas` - array of schema names that are eligible to be served via GraphQL.
20
+ * `Rails.application.config.graphql_traffic_rates` - hash mapping schema names to traffic percentages (as floats between 0 and 1.0).
21
+
22
+ Traffic rates for individual schemas are set in `govuk-helm-charts` repository as environment variables, with the following format `GRAPHQL_RATE_<SCHEMA_NAME>`.
23
+
24
+ ### Usage
25
+
26
+ To enable this functionality, create a file `config/initializers/govuk_graphql_traffic_rates.rb` in the app containing:
27
+
28
+ ```ruby
29
+ GovukGraphqlTrafficRates.configure
30
+ ```
31
+
32
+ ## Conditional Content Item Loader
33
+
34
+ This class acts as a traffic-splitting content loader that decides, per request, whether to fetch a content item from the **Publishing API via GraphQL** or fall back to the **Content Store**, based on request parameters and configured traffic-shaping rules based on content schemas.
35
+
36
+ **In practice, it:**
37
+
38
+ * Chooses between GraphQL (Publishing API) and the Content Store when loading a content item
39
+ * Always disables GraphQL on draft hosts
40
+ * Allows GraphQL to be explicitly forced on or off via the `graphql` request parameter
41
+ * Falls back to the Content Store unless the content item’s schema is explicitly allow-listed
42
+ * Uses a per-schema traffic rate to probabilistically route a percentage of requests to GraphQL
43
+ * Records GraphQL success, error status codes and timeouts in Prometheus request labels
44
+ * Propagates GdsApi errors
45
+
46
+ ### Usage
47
+
48
+ #### Prerequisites
49
+
50
+ 1. Ensure application configuration sets list of schema names that are eligible to be served via GraphQL and hash mapping schema names to traffic percentages via `GovukGraphqlTrafficRates` described above.
51
+
52
+ 2. Ensure the `PLEK_SERVICE_PUBLISHING_API_URI` environment variable is set in **govuk-helm-charts** for the live app (draft stack is handled via [`PLEK_HOSTNAME_PREFIX`](https://github.com/alphagov/plek/blob/main/CHANGELOG.md#410)), across all relevant environments, so the Publishing API (GraphQL) can be correctly resolved at runtime:
53
+
54
+ ```yaml
55
+ - name: PLEK_SERVICE_PUBLISHING_API_URI
56
+ value: "http://publishing-api-read-replica"
57
+ ```
58
+
59
+ 3. (Assumed true for all frontend apps) Ensure the application has Content Store connection configuration.
60
+
61
+ #### Using the loader
62
+
63
+ `GovukConditionalContentItemLoader` is intended to be used at the point where a request needs to load a content item, and where traffic may be conditionally routed to GraphQL instead of the Content Store.
64
+
65
+ At its simplest, you initialise the loader with the current request and call load:
66
+ ```
67
+ loader = GovukConditionalContentItemLoader.new(request: request)
68
+ content_item = loader.load
69
+ ```
70
+
71
+ By default, the loader uses:
72
+ * `GdsApi.content_store` to fetch content from the Content Store
73
+ * `GdsApi.publishing_api` to fetch content via GraphQL
74
+
75
+ These defaults make it suitable for use in production code without additional configuration.
76
+
77
+ *Passing custom clients*
78
+ For testing or specialised use cases, you can explicitly pass in the API clients:
79
+ ```
80
+ loader = GovukGraphql::ConditionalContentItemLoader.new(
81
+ request: request,
82
+ content_store_client: content_store_client,
83
+ publishing_api_client: publishing_api_client,
84
+ )
85
+ ```
86
+
87
+ The decision logic is also exposed via `can_load_from_graphql?`, allowing applications to make the routing decision themselves and implement custom fallback or error-handling behaviour if needed.
88
+
5
89
  ## Licence
6
90
 
7
91
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,73 @@
1
+ # ADR 1: Create a dedicated gem for conditional GraphQL content loading
2
+ Status: Accepted
3
+ Date: 2026-02-17
4
+
5
+ ## Context
6
+ As part of the migration to GraphQL-backed content retrieval [RFC-172](https://github.com/alphagov/govuk-rfcs/blob/main/rfc-172-graphql-for-govuk.md), we are introducing a temporary (medium-term) ConditionalContentItemLoader that determines whether a request should load content from:
7
+ * The Content Store (REST), or
8
+ * The Publishing API via GraphQL
9
+
10
+ This decision is made at request time and depends on:
11
+ * Request parameters
12
+ * Environment state (e.g. draft host)
13
+ * Allow-list configuration
14
+ * Probabilistic traffic splitting
15
+
16
+ It includes Prometheus metrics labelling for monitoring and alerting.
17
+
18
+ The loader is required across seven frontend applications.
19
+
20
+ ## Problem
21
+ We need a centralised implementation to:
22
+ * Avoid duplication and divergence across multiple apps
23
+ * Maintain clear architectural boundaries
24
+ * Keep configuration and routing logic co-located
25
+
26
+ ## Decision
27
+ We will create a Ruby gem to encapsulate the conditional content loader and its configuration, with minimal dependencies.
28
+
29
+ ## Rationale
30
+ 1. Shared across multiple applications
31
+ One gem ensures consistent behaviour and easier rollout coordination.
32
+
33
+ 1. Maintenance burden is acceptable
34
+ The gem is small, with few dependencies.
35
+
36
+ 1. Not a good fit for `gds-api-adapters`
37
+ `gds-api-adapters` is designed around a clear architectural pattern:
38
+ * Each adapter corresponds to a single external API
39
+ * Adapter methods map directly to HTTP endpoints
40
+ * Namespaces reflect a 1:1 relationship with APIs
41
+
42
+ The conditional loader does not fit this model as it's a higher-level orchestration layer, not a client abstraction. Adding it to gds-api-adapters would:
43
+ * Blur architectural boundaries
44
+ * Violate separation of concerns
45
+ * Break the established software design pattern of the gem
46
+
47
+ 1. Not a good fit for `govuk_app_config`
48
+ That library is intended for generic application configuration, not behaviour orchestration used by a subset of apps.
49
+
50
+ 1. Explicitly not using `govuk_ab_testing`
51
+ `govuk_ab_testing` was considered. This is not a generic A/B test or experiment framework use case. Embedding this logic in govuk_ab_testing would conflate experimentation infrastructure with migration routing logic.
52
+
53
+ 1. Co-location with configuration
54
+ The loader depends on configuration established via GovukGraphqlTrafficRates.configure which would fit in govuk_app_config. However, keeping it in the same gem reduces complexity and risk of misconfiguration.
55
+
56
+ ## Alternatives considered
57
+ - Duplicate in each app – rejected due to divergence risk.
58
+ - Add to `gds-api-adapters` – rejected; violates separation of concerns.
59
+ - Add to `govuk_app_config` – rejected; not just application configuration.
60
+ - Add to `govuk_ab_testing` – rejected; not experimentation logic.
61
+
62
+ ## Consequences
63
+
64
+ ### Positive
65
+ * Consistent behaviour across multiple applications
66
+ * Clean architectural separation
67
+ * Centralised rollout
68
+ * Clear ownership of GraphQL migration logic
69
+ * Versioned upgrades
70
+
71
+ ### Negative
72
+ * One additional gem to maintain
73
+ * Increase in dependency management overhead
@@ -20,7 +20,10 @@ Gem::Specification.new do |spec|
20
20
  spec.require_paths = %w[lib]
21
21
 
22
22
  spec.add_dependency "gds-api-adapters", ">= 99.3"
23
+ spec.add_dependency "railties", ">= 7.2"
23
24
 
25
+ spec.add_development_dependency "climate_control"
26
+ spec.add_development_dependency "ostruct"
24
27
  spec.add_development_dependency "rails", ">= 7.2"
25
28
  spec.add_development_dependency "rake", ">= 10.0"
26
29
  spec.add_development_dependency "rspec", "~> 3.0"
@@ -0,0 +1,76 @@
1
+ class GovukConditionalContentItemLoader
2
+ attr_reader :content_store_client, :publishing_api_client, :request, :base_path
3
+
4
+ def initialize(request:, content_store_client: GdsApi.content_store, publishing_api_client: GdsApi.publishing_api)
5
+ @content_store_client = content_store_client
6
+ @publishing_api_client = publishing_api_client
7
+ @request = request
8
+ @base_path = request&.path
9
+ end
10
+
11
+ def load
12
+ return content_item_from_content_store unless can_load_from_graphql?
13
+
14
+ content_item_from_graphql || content_item_from_content_store
15
+ end
16
+
17
+ def can_load_from_graphql?
18
+ return false unless request
19
+
20
+ return false if draft_host?
21
+
22
+ return force_graphql_param unless force_graphql_param.nil?
23
+
24
+ schema_name = content_item_from_content_store["schema_name"]
25
+ return false unless graphql_schema_allowed?(schema_name)
26
+
27
+ within_graphql_traffic_rate?(schema_name)
28
+ end
29
+
30
+ private
31
+
32
+ def content_item_from_graphql
33
+ set_prometheus_labels
34
+ publishing_api_client.graphql_live_content_item(base_path)
35
+ rescue GdsApi::HTTPErrorResponse => e
36
+ set_prometheus_labels(graphql_status_code: e.code)
37
+ false
38
+ rescue GdsApi::TimedOutException
39
+ set_prometheus_labels(graphql_api_timeout: true)
40
+ false
41
+ end
42
+
43
+ def content_item_from_content_store
44
+ @content_item_from_content_store ||= content_store_client.content_item(base_path)
45
+ end
46
+
47
+ def set_prometheus_labels(graphql_status_code: 200, graphql_api_timeout: false)
48
+ prometheus_labels = request.env.fetch("govuk.prometheus_labels", {})
49
+
50
+ hash = {
51
+ "graphql_status_code" => graphql_status_code,
52
+ "graphql_api_timeout" => graphql_api_timeout,
53
+ }
54
+
55
+ request.env["govuk.prometheus_labels"] = prometheus_labels.merge(hash)
56
+ end
57
+
58
+ def draft_host?
59
+ ENV["PLEK_HOSTNAME_PREFIX"] == "draft-"
60
+ end
61
+
62
+ def force_graphql_param
63
+ return true if request.params["graphql"] == "true"
64
+
65
+ false if request.params["graphql"] == "false"
66
+ end
67
+
68
+ def graphql_schema_allowed?(schema_name)
69
+ Rails.application.config.graphql_allowed_schemas&.include?(schema_name) || false
70
+ end
71
+
72
+ def within_graphql_traffic_rate?(schema_name)
73
+ graphql_traffic_rate = Rails.application.config.graphql_traffic_rates&.dig(schema_name) || 0
74
+ Random.rand(1.0) < graphql_traffic_rate
75
+ end
76
+ end
@@ -0,0 +1,15 @@
1
+ module GovukGraphqlTrafficRates
2
+ def self.configure
3
+ rates = graphql_rates_from_env
4
+
5
+ Rails.application.config.graphql_traffic_rates = rates
6
+ Rails.application.config.graphql_allowed_schemas = rates.keys
7
+ end
8
+
9
+ def self.graphql_rates_from_env
10
+ ENV
11
+ .select { |key, _| key.start_with?("GRAPHQL_RATE_") }
12
+ .transform_keys { |key| key.delete_prefix("GRAPHQL_RATE_").downcase }
13
+ .transform_values(&:to_f)
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module GovukContentItemLoader
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "1.1.0".freeze
3
3
  end
@@ -1 +1,3 @@
1
+ require "govuk_content_item_loader/govuk_graphql_traffic_rates"
2
+ require "govuk_content_item_loader/govuk_conditional_content_item_loader"
1
3
  require "govuk_content_item_loader/version"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: govuk_content_item_loader
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GOV.UK Dev
@@ -23,6 +23,48 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '99.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: climate_control
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ostruct
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
26
68
  - !ruby/object:Gem::Dependency
27
69
  name: rails
28
70
  requirement: !ruby/object:Gem::Requirement
@@ -115,8 +157,11 @@ files:
115
157
  - LICENCE
116
158
  - README.md
117
159
  - Rakefile
160
+ - docs/adr/01-new-gem-for-conditional-content-loading.md
118
161
  - govuk_content_item_loader.gemspec
119
162
  - lib/govuk_content_item_loader.rb
163
+ - lib/govuk_content_item_loader/govuk_conditional_content_item_loader.rb
164
+ - lib/govuk_content_item_loader/govuk_graphql_traffic_rates.rb
120
165
  - lib/govuk_content_item_loader/version.rb
121
166
  homepage: https://github.com/alphagov/govuk_content_item_loader
122
167
  licenses:
@@ -136,7 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
136
181
  - !ruby/object:Gem::Version
137
182
  version: '0'
138
183
  requirements: []
139
- rubygems_version: 4.0.6
184
+ rubygems_version: 4.0.7
140
185
  specification_version: 4
141
186
  summary: Provides a standardised content item loader for GOV.UK frontend apps with
142
187
  configurable GraphQL traffic routing and automatic fallback to the Content Store