Class: SiteSearch::SwiftypeAdapter

Inherits:
Object
  • Object
show all
Includes:
Service
Defined in:
app/services/site_search/swiftype_adapter.rb

Overview

Queries Swiftype (Elastic Site Search) for everything that isn't a publication.

This used to run in the browser — search_controller.js POSTed to
api.swiftype.com directly with the engine key inlined in the bundle. Moving it
behind our own endpoint means the engine is swappable without touching the
client (see doc/tasks/202512141800_SWIFTYPE_REPLACEMENT.md) and the response
becomes edge-cacheable.

Search must degrade, never 500: any transport or parse failure returns an empty
page so the publications half of a blended query still reaches the user.

Defined Under Namespace

Classes: Result

Constant Summary collapse

ENDPOINT =

Swiftype's public search endpoint.

'https://api.swiftype.com/api/v1/public/engines/search'
PER_PAGE =

Results requested per page.

10
OPEN_TIMEOUT =

Bounded, because this sits in a user's request path — but not tight. This
covers TCP and TLS, and the handshake to Swiftype measured up to 0.82s
from a developer laptop; a 2s ceiling tripped on a real search and silently
served an empty Product tab (development.log, 2026-08-12).

5
TIMEOUT =

Seconds to wait for the whole call. A slow engine should cost a blended
search its Swiftype half, not the whole response.

8
DOMAIN_IDENTIFIERS =

Swiftype scopes en-CA content by a domain-identifier facet.

{ 'en-CA' => 2 }.freeze
DEFAULT_DOMAIN_IDENTIFIER =

The US domain facet, used for every locale but en-CA.

1

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Service

#attributes_used, #build_result, call, #logger

Methods included from Service::Initializer

#initialize

Class Method Details

.engine_keyString?

Swiftype's engine key, from credentials (swiftype_engine_key).

Swiftype calls this the public key — it authorizes search-only access to
one engine — and it shipped inside the JS bundle for years. It lives in
credentials anyway: the secrets gate reads it as an API key because that is
exactly what it is, and rotating it should not need a code change.

Resolved per call rather than at class load so a rotation takes effect on
the next request instead of the next boot.

Returns:

  • (String, nil)


45
46
47
# File 'app/services/site_search/swiftype_adapter.rb', line 45

def self.engine_key
  Heatwave::Configuration.fetch(:swiftype_engine_key)
end

.proxy_keyString?

Returns shared key the worker requires (x-proxy-key).

Returns:

  • (String, nil)

    shared key the worker requires (x-proxy-key)



63
64
65
# File 'app/services/site_search/swiftype_adapter.rb', line 63

def self.proxy_key
  Heatwave::Configuration.fetch(:swiftype_proxy_key)
end

.proxy_urlString?

Egress proxy (Cloudflare Worker), production-scoped in credentials. Swiftype
expects distributed browser traffic; calling it straight from the app server
concentrated every visitor's search onto one IP, and their network banned it
within a minute of a burst (2026-08-13, connect timeouts on every SYN).
Routing through the worker gives the call Cloudflare egress instead. Blank
outside production, so dev talks to Swiftype directly and tests stub the
real endpoint.

Returns:

  • (String, nil)

    the worker URL, or nil to call Swiftype directly



58
59
60
# File 'app/services/site_search/swiftype_adapter.rb', line 58

def self.proxy_url
  Heatwave::Configuration.fetch(:swiftype_proxy_url)
end

Instance Method Details

#callResult

Returns one page of engine results, empty when the engine is down.

Returns:

  • (Result)

    one page of engine results, empty when the engine is down



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/services/site_search/swiftype_adapter.rb', line 83

def call
  return empty if query.blank?
  # A missing key means every search would come back empty and look like a
  # catalogue with nothing in it; say the engine is down instead.
  return empty(degraded: true) if engine_key.blank?

  response = post
  # Anything that isn't a 2xx — a 429, a 5xx, or a transport failure — means
  # the engine did not answer. Only a successful reply can say "no matches".
  unless response&.success?
    report_rejection(response) if response
    return empty(degraded: true)
  end

  parse(response.body)
end