From b2260504fb7ff8600488c878ccaf414413e91a8d Mon Sep 17 00:00:00 2001 From: Christian Sutter Date: Tue, 20 Aug 2024 10:29:57 +0000 Subject: [PATCH] Add autocomplete API Adds an API endpoint (`/autocomplete.json`) to return results for an autocomplete component we are currently building. This will return query suggestions for the user as they type, using the Discovery Engine `CompletionService` API. - Add controller/route/request spec for API endpoint and basic ActiveModel data class for result along the lines of existing search API - Add `DiscoveryEngine::Autocomplete::Complete` library to interact with Discovery Engine autocomplete feature - Add Rails configuration for (existing) `DISCOVERY_ENGINE_DATASTORE` environment variable --- .github/workflows/ci.yml | 1 + app/controllers/autocompletes_controller.rb | 11 +++++ app/models/completion_result.rb | 6 +++ .../discovery_engine/autocomplete/complete.rb | 43 +++++++++++++++++++ config/application.rb | 1 + config/routes.rb | 1 + spec/requests/autocomplete_request_spec.rb | 20 +++++++++ .../autocomplete/complete_spec.rb | 40 +++++++++++++++++ 8 files changed, 123 insertions(+) create mode 100644 app/controllers/autocompletes_controller.rb create mode 100644 app/models/completion_result.rb create mode 100644 app/services/discovery_engine/autocomplete/complete.rb create mode 100644 spec/requests/autocomplete_request_spec.rb create mode 100644 spec/services/discovery_engine/autocomplete/complete_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7682d75..c1b942a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: RAILS_ENV: test # All Google client library calls are mocked, but the application needs this set to boot DISCOVERY_ENGINE_SERVING_CONFIG: not-used + DISCOVERY_ENGINE_DATASTORE: not-used DISCOVERY_ENGINE_DATASTORE_BRANCH: not-used # Redis running through govuk-infrastructure action REDIS_URL: redis://localhost:6379 diff --git a/app/controllers/autocompletes_controller.rb b/app/controllers/autocompletes_controller.rb new file mode 100644 index 0000000..a109d3f --- /dev/null +++ b/app/controllers/autocompletes_controller.rb @@ -0,0 +1,11 @@ +class AutocompletesController < ApplicationController + def show + render json: DiscoveryEngine::Autocomplete::Complete.new(query).completion_result + end + +private + + def query + params.permit(:q)[:q] + end +end diff --git a/app/models/completion_result.rb b/app/models/completion_result.rb new file mode 100644 index 0000000..16de469 --- /dev/null +++ b/app/models/completion_result.rb @@ -0,0 +1,6 @@ +# Represents a set of query completion results for an autocomplete feature +class CompletionResult + include ActiveModel::Model + + attr_accessor :suggestions +end diff --git a/app/services/discovery_engine/autocomplete/complete.rb b/app/services/discovery_engine/autocomplete/complete.rb new file mode 100644 index 0000000..2084e8b --- /dev/null +++ b/app/services/discovery_engine/autocomplete/complete.rb @@ -0,0 +1,43 @@ +module DiscoveryEngine::Autocomplete + class Complete + QUERY_MODEL = "user-event".freeze + + def initialize( + query, + client: ::Google::Cloud::DiscoveryEngine.completion_service(version: :v1) + ) + @query = query + @client = client + end + + def completion_result + CompletionResult.new(suggestions:) + end + + private + + def suggestions + # Discovery Engine returns an error on an empty query, so we need to handle it ourselves + return [] if query.blank? + + client + .complete_query(complete_query_request) + .query_suggestions + .map(&:suggestion) + end + + def complete_query_request + { + data_store:, + query:, + query_model: QUERY_MODEL, + } + end + + def data_store + Rails.configuration.discovery_engine_datastore + end + + attr_reader :query, :client + end +end diff --git a/config/application.rb b/config/application.rb index f11f8e9..2a8d784 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,6 +18,7 @@ class Application < Rails::Application # Google Discovery Engine configuration config.discovery_engine_serving_config = ENV.fetch("DISCOVERY_ENGINE_SERVING_CONFIG") + config.discovery_engine_datastore = ENV.fetch("DISCOVERY_ENGINE_DATASTORE") config.discovery_engine_datastore_branch = ENV.fetch("DISCOVERY_ENGINE_DATASTORE_BRANCH") # Document sync configuration diff --git a/config/routes.rb b/config/routes.rb index 29c4123..163dfc2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do resource :search, only: [:show] + resource :autocomplete, only: [:show] # Healthchecks get "/healthcheck/live", to: proc { [200, {}, %w[OK]] } diff --git a/spec/requests/autocomplete_request_spec.rb b/spec/requests/autocomplete_request_spec.rb new file mode 100644 index 0000000..ce6c0ee --- /dev/null +++ b/spec/requests/autocomplete_request_spec.rb @@ -0,0 +1,20 @@ +RSpec.describe "Making an autocomplete request" do + let(:autocomplete_service) { instance_double(DiscoveryEngine::Autocomplete::Complete, completion_result:) } + let(:completion_result) { CompletionResult.new(suggestions: %w[foo foobar foobaz]) } + + before do + allow(DiscoveryEngine::Autocomplete::Complete).to receive(:new) + .with("foo").and_return(autocomplete_service) + end + + describe "GET /autocomplete.json" do + it "returns a set of suggestions as JSON" do + get "/autocomplete.json?q=foo" + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + "suggestions" => %w[foo foobar foobaz], + }) + end + end +end diff --git a/spec/services/discovery_engine/autocomplete/complete_spec.rb b/spec/services/discovery_engine/autocomplete/complete_spec.rb new file mode 100644 index 0000000..960d2bc --- /dev/null +++ b/spec/services/discovery_engine/autocomplete/complete_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe DiscoveryEngine::Autocomplete::Complete do + subject(:completion) { described_class.new(query, client:) } + + let(:client) { double("CompletionService::Client", complete_query:) } + + before do + allow(Rails.configuration).to receive(:discovery_engine_datastore).and_return("/the/datastore") + end + + describe "#completion_result" do + subject(:completion_result) { completion.completion_result } + + let(:query) { "foo" } + let(:complete_query) { double("response", query_suggestions:) } + let(:query_suggestions) { %w[foo foobar foobaz].map { double("suggestion", suggestion: _1) } } + + it "returns the suggestions from the search response" do + expect(completion_result.suggestions).to eq(%w[foo foobar foobaz]) + end + + it "makes a request to the completion service with the right parameters" do + completion_result + + expect(client).to have_received(:complete_query).with( + data_store: "/the/datastore", + query:, + query_model: "user-event", + ) + end + + context "when the query is empty" do + let(:query) { "" } + + it "returns an empty array and does not make a request" do + expect(completion_result.suggestions).to eq([]) + expect(client).not_to have_received(:complete_query) + end + end + end +end