Skip to content

[ACM-16232] Add support for search-api Subscriptions#6097

Open
zlayne wants to merge 3 commits intostolostron:mainfrom
zlayne:16232-search-subscription-support
Open

[ACM-16232] Add support for search-api Subscriptions#6097
zlayne wants to merge 3 commits intostolostron:mainfrom
zlayne:16232-search-subscription-support

Conversation

@zlayne
Copy link
Copy Markdown
Contributor

@zlayne zlayne commented May 5, 2026

Generated-by: Cursor (auto)

📝 Summary

Ticket Summary (Title):
Adds support for Search API GraphQL Subscriptions.
Developers will now be able to utilize the useSearchSubscriptionSubscription hook to start a subscription and watch for change events on resources that match the provided search parameters.

Ticket Link:
https://issues.redhat.com/browse/ACM-16232

Type of Change:

  • 🐞 Bug Fix
  • ✨ Feature
  • 🔧 Refactor
  • 💸 Tech Debt
  • 🧪 Test-related
  • 📄 Docs

✅ Checklist

General

  • PR title follows the convention (e.g. ACM-12340 Fix bug with...)
  • Code builds and runs locally without errors
  • No console logs, commented-out code, or unnecessary files
  • All commits are meaningful and well-labeled
  • All new display strings are externalized for localization (English only)
  • (Nice to have) JSDoc comments added for new functions and interfaces

If Feature

  • UI/UX reviewed (if applicable)
  • All acceptance criteria met
  • Unit test coverage added or updated
  • Relevant documentation or comments included

If Bugfix

  • Root cause and fix summary are documented in the ticket (for future reference / errata)
  • Fix tested thoroughly and resolves the issue
  • Test(s) added to prevent regression

🗒️ Notes for Reviewers

Summary by CodeRabbit

  • New Features

    • Real-time search: client subscriptions via WebSocket proxying with secure upstream connections.
    • Added pagination (offset) and sorting (orderBy) for search queries.
    • Introduced an Event type for search-change events and a React hook to subscribe to live watch events.
  • Tests

    • Expanded tests covering WebSocket relay behavior and injection of Authorization into initial connection messages.
  • Chores

    • Added WebSocket and GraphQL subscription dependencies.

Generated-by: Cursor (auto)
Signed-off-by: zlayne <zlayne@redhat.com>
@openshift-ci
Copy link
Copy Markdown

openshift-ci Bot commented May 5, 2026

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: zlayne

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci openshift-ci Bot added the approved label May 5, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

Adds WebSocket-based GraphQL subscription transport for search: backend upgrades /multicloud/proxy/search to a WebSocket relay that injects an Authorization Bearer token into the client's initial connection_init and proxies frames to an upstream wss search API; frontend adds subscription types and routes subscription operations over graphql-ws.

Changes

Search WebSocket Subscriptions

Layer / File(s) Summary
Dependencies
backend/package.json, frontend/package.json
Added ws and @types/ws to backend; added graphql-ws to frontend.
Server wiring
backend/src/lib/server.ts
HTTP/2 upgrade handler now matches /multicloud/proxy/search, strips the prefix from req.url, and delegates to searchWebSocket before the managed-cluster proxy branch.
Helpers / Types
backend/src/routes/search.ts
Added case-insensitive header lookup and helpers for UTF‑8 normalization and upstream subprotocol selection.
Auth injection helper
backend/src/routes/search.ts
New exported injectSearchWsConnectionInitAuthorization(connectionInitJson, bearerToken) that parses connection_init JSON and injects payload.Authorization: "Bearer <token>" (no-op on non-matching messages or invalid JSON).
WebSocket relay core
backend/src/routes/search.ts
Added exported searchWebSocket(req, socket, head) — obtains bearer token from socket, builds upstream wss:// URL and HTTPS agent with service CA, opens upstream WebSocket with connect/upgrade timeouts, completes browser upgrade via a WebSocketServer({ noServer: true }), and relays frames both directions while rewriting the first non-binary connection_init to include Authorization. On early failures writes an HTTP error response to the raw socket and destroys it.
Removed HTTP proxy
backend/src/routes/search.ts
Removed the prior HTTP POST GraphQL proxy handler (search) replaced by the WebSocket relay.
Backend tests
backend/test/routes/searchWebSocket.test.ts
New comprehensive tests for injectSearchWsConnectionInitAuthorization and searchWebSocket covering auth injection, header/protocol handling, timeouts/error responses before upgrade, upgrade wiring, URL port behavior, and bidirectional relay/lifecycle behavior.
Frontend subscription schema/types
frontend/src/routes/Search/search-sdk/search-sdk.ts, frontend/src/routes/Search/search-sdk/subscription.graphql
Added Date scalar, Event type, extended SearchInput with offset/orderBy, added Subscription/SubscriptionWatchArgs, generated SearchSubscriptionDocument and useSearchSubscriptionSubscription React hook and related types.
Frontend client wiring
frontend/src/routes/Search/search-sdk/search-client.ts
Added getSearchWebSocketUrl() helper; added wsLink using graphql-ws; replaced the single HTTP link with a split that sends subscription operations to wsLink and other operations through the existing HTTP chain (CSRF + httpLink).

Sequence Diagram

sequenceDiagram
    participant Browser as Browser (graphql-ws)
    participant Backend as Backend Server
    participant SearchAPI as Search API

    Browser->>Backend: HTTP/2 Upgrade Request /multicloud/proxy/search
    Backend->>Backend: Rewrite URL (strip /multicloud/proxy)
    Backend->>SearchAPI: Open upstream WSS (wss://..., HTTPS agent with CA)
    SearchAPI-->>Backend: Upgrade Accept
    Backend-->>Browser: Upgrade Accept

    Browser->>Backend: connection_init (no Authorization)
    Backend->>Backend: inject Authorization into payload
    Backend->>SearchAPI: connection_init (with Authorization)
    SearchAPI-->>Backend: connection_ack
    Backend-->>Browser: connection_ack

    Browser->>Backend: subscription: watch($input)
    Backend->>SearchAPI: subscription: watch($input)
    SearchAPI-->>Backend: event frames
    Backend-->>Browser: event frames
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and specifically identifies the main change: adding subscription support to the search API with the ticket reference.
Description check ✅ Passed Description is mostly complete with ticket link, clear summary, and feature type selection checked, though several general checklist items remain unchecked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/test/routes/searchWebSocket.test.ts (1)

4-25: 🏗️ Heavy lift

The new suite only covers the JSON helper, not the relay path.

The feature logic lives in searchWebSocket itself: unauthenticated upgrades, upgrade completion, and the first-frame rewrite/forwarding path. A regression there would still pass this file, so please add at least one test that exercises the websocket flow rather than only injectSearchWsConnectionInitAuthorization.

As per coding guidelines, "Verify test coverage is meaningful, not just for metrics" and "Before claiming what the code under test does or allows, confirm it from the actual source (e.g. config, implementation); do not infer behavior from test names or comments alone".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/routes/searchWebSocket.test.ts` around lines 4 - 25, The test
suite only verifies the JSON helper injectSearchWsConnectionInitAuthorization;
add at least one integration-style test that exercises the searchWebSocket flow
itself (the upgrade handling, unauthenticated upgrade path, upgrade completion,
and the first-frame rewrite/forwarding) so regressions in the actual handler are
caught. Specifically, create a test that calls the searchWebSocket handler (or
spins up a lightweight http server and performs a WebSocket upgrade against it)
and asserts that: unauthenticated upgrade requests are handled per
implementation, the upgrade completion path is executed, and the first incoming
connection_init frame is rewritten to include the Authorization header (or
forwarded unchanged when Bearer present) before being proxied — referencing the
searchWebSocket entrypoint and injectSearchWsConnectionInitAuthorization to
locate the relevant logic. Ensure the test mocks/stubs the downstream
socket/proxy behavior to observe the forwarded first-frame and any upgrade
completion callbacks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/routes/search.ts`:
- Around line 175-177: The code builds headers with `Bearer ${token}` even when
`getAuthenticatedToken(req, socket)` returns a falsy token, causing
unauthenticated websocket upgrades to be forwarded; modify the `search` flow to
immediately stop the websocket path when `token` is falsy (e.g., send an auth
failure response / close the socket or return a 401) and skip creating `headers`
and calling `getSearchRequestOptions`; only construct `headers:
OutgoingHttpHeaders = { authorization: \`Bearer ${token}\` }` and call
`getSearchRequestOptions(headers)` when `token` is truthy.

---

Nitpick comments:
In `@backend/test/routes/searchWebSocket.test.ts`:
- Around line 4-25: The test suite only verifies the JSON helper
injectSearchWsConnectionInitAuthorization; add at least one integration-style
test that exercises the searchWebSocket flow itself (the upgrade handling,
unauthenticated upgrade path, upgrade completion, and the first-frame
rewrite/forwarding) so regressions in the actual handler are caught.
Specifically, create a test that calls the searchWebSocket handler (or spins up
a lightweight http server and performs a WebSocket upgrade against it) and
asserts that: unauthenticated upgrade requests are handled per implementation,
the upgrade completion path is executed, and the first incoming connection_init
frame is rewritten to include the Authorization header (or forwarded unchanged
when Bearer present) before being proxied — referencing the searchWebSocket
entrypoint and injectSearchWsConnectionInitAuthorization to locate the relevant
logic. Ensure the test mocks/stubs the downstream socket/proxy behavior to
observe the forwarded first-frame and any upgrade completion callbacks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 620ef262-4f4f-4fbc-8bb0-c41c9c0b24df

📥 Commits

Reviewing files that changed from the base of the PR and between 0c671d0 and 8e11054.

⛔ Files ignored due to path filters (2)
  • backend/package-lock.json is excluded by !**/package-lock.json
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • backend/package.json
  • backend/src/lib/server.ts
  • backend/src/routes/search.ts
  • backend/test/routes/searchWebSocket.test.ts
  • frontend/package.json
  • frontend/src/routes/Search/search-sdk/search-client.ts
  • frontend/src/routes/Search/search-sdk/search-sdk.ts
  • frontend/src/routes/Search/search-sdk/subscription.graphql

Comment thread backend/src/routes/search.ts
zlayne added 2 commits May 5, 2026 13:57
Signed-off-by: zlayne <zlayne@redhat.com>
Generated-by: Cursor (auto)
Signed-off-by: zlayne <zlayne@redhat.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/test/routes/searchWebSocket.test.ts`:
- Around line 247-277: The tests currently only check mockWsInstance exists and
thus don't verify the upstream URL or headers; update each test (in
searchWebSocket.test.ts) to assert the ws constructor was called with the
expected URL and header args instead of just checking mockWsInstance.
Specifically, replace expect(mockWsInstance).toBeDefined() with assertions on
mockWsConstructor (or whatever jest mock wraps the ws client) to inspect
mockWsConstructor.mock.calls[0][0] equals the expected URL (e.g.,
"wss://search.example.com/graphql" when port===443 and
"wss://search.example.com:4010/graphql" when port!==443) and
mockWsConstructor.mock.calls[0][1] (or the headers object) contains the
forwarded 'sec-websocket-protocol' value when using makeMockReq with that
header; reference searchWebSocket, makeMockReq, mockGetSearchOpts,
mockWsConstructor/mockWsInstance to locate the mocks and replace the
non-verifying expect with these concrete call-argument checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 378340bd-c598-420b-a7e0-4fdfb93f96a4

📥 Commits

Reviewing files that changed from the base of the PR and between 56b2169 and cf631c1.

📒 Files selected for processing (1)
  • backend/test/routes/searchWebSocket.test.ts

Comment on lines +247 to +277
it('constructs upstream URL without port when port is 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 443, path: '/graphql' })

await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))

// If upstream was created without throwing we verify it exists; URL is wss://host/path (no port)
expect(mockWsInstance).toBeDefined()
})

it('constructs upstream URL with port when port is not 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 4010, path: '/graphql' })

await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))

expect(mockWsInstance).toBeDefined()
})

it('uses the sec-websocket-protocol header from the browser request for the upstream', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS)

await searchWebSocket(makeMockReq({ 'sec-websocket-protocol': 'graphql-ws' }), socket, Buffer.alloc(0))

// Upstream WS was created; the test verifies the function completes without error
expect(mockWsInstance).toBeDefined()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

URL/protocol tests are non-verifying and can pass on regressions.

These tests currently pass even if URL or protocol forwarding breaks, because mockWsInstance is always defined from beforeEach. Assert the mocked ws constructor arguments (URL + headers) directly.

Proposed fix
+import WebSocket from 'ws'
...
+const mockWebSocketCtor = WebSocket as unknown as jest.Mock
...
 it('constructs upstream URL without port when port is 443', async () => {
   const socket = makeMockSocket()
   mockGetAuthToken.mockResolvedValue('tok')
   mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 443, path: '/graphql' })

   await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))

-  // If upstream was created without throwing we verify it exists; URL is wss://host/path (no port)
-  expect(mockWsInstance).toBeDefined()
+  expect(mockWebSocketCtor).toHaveBeenCalledWith(
+    'wss://search.example.com/graphql',
+    expect.objectContaining({
+      headers: expect.objectContaining({ host: 'search.example.com' }),
+    })
+  )
 })

 it('constructs upstream URL with port when port is not 443', async () => {
   const socket = makeMockSocket()
   mockGetAuthToken.mockResolvedValue('tok')
   mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 4010, path: '/graphql' })

   await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))

-  expect(mockWsInstance).toBeDefined()
+  expect(mockWebSocketCtor).toHaveBeenCalledWith(
+    'wss://search.example.com:4010/graphql',
+    expect.objectContaining({
+      headers: expect.objectContaining({ host: 'search.example.com:4010' }),
+    })
+  )
 })

 it('uses the sec-websocket-protocol header from the browser request for the upstream', async () => {
   const socket = makeMockSocket()
   mockGetAuthToken.mockResolvedValue('tok')
   mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS)

   await searchWebSocket(makeMockReq({ 'sec-websocket-protocol': 'graphql-ws' }), socket, Buffer.alloc(0))

-  // Upstream WS was created; the test verifies the function completes without error
-  expect(mockWsInstance).toBeDefined()
+  expect(mockWebSocketCtor).toHaveBeenCalledWith(
+    expect.any(String),
+    expect.objectContaining({
+      headers: expect.objectContaining({ 'sec-websocket-protocol': 'graphql-ws' }),
+    })
+  )
 })

As per coding guidelines, **/*.test.{ts,tsx}: "Verify test coverage is meaningful, not just for metrics" and "Check that tests actually test the described behavior".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('constructs upstream URL without port when port is 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 443, path: '/graphql' })
await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))
// If upstream was created without throwing we verify it exists; URL is wss://host/path (no port)
expect(mockWsInstance).toBeDefined()
})
it('constructs upstream URL with port when port is not 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 4010, path: '/graphql' })
await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))
expect(mockWsInstance).toBeDefined()
})
it('uses the sec-websocket-protocol header from the browser request for the upstream', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS)
await searchWebSocket(makeMockReq({ 'sec-websocket-protocol': 'graphql-ws' }), socket, Buffer.alloc(0))
// Upstream WS was created; the test verifies the function completes without error
expect(mockWsInstance).toBeDefined()
})
it('constructs upstream URL without port when port is 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 443, path: '/graphql' })
await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))
expect(mockWebSocketCtor).toHaveBeenCalledWith(
'wss://search.example.com/graphql',
expect.objectContaining({
headers: expect.objectContaining({ host: 'search.example.com' }),
})
)
})
it('constructs upstream URL with port when port is not 443', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 4010, path: '/graphql' })
await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0))
expect(mockWebSocketCtor).toHaveBeenCalledWith(
'wss://search.example.com:4010/graphql',
expect.objectContaining({
headers: expect.objectContaining({ host: 'search.example.com:4010' }),
})
)
})
it('uses the sec-websocket-protocol header from the browser request for the upstream', async () => {
const socket = makeMockSocket()
mockGetAuthToken.mockResolvedValue('tok')
mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS)
await searchWebSocket(makeMockReq({ 'sec-websocket-protocol': 'graphql-ws' }), socket, Buffer.alloc(0))
expect(mockWebSocketCtor).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ 'sec-websocket-protocol': 'graphql-ws' }),
})
)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/routes/searchWebSocket.test.ts` around lines 247 - 277, The
tests currently only check mockWsInstance exists and thus don't verify the
upstream URL or headers; update each test (in searchWebSocket.test.ts) to assert
the ws constructor was called with the expected URL and header args instead of
just checking mockWsInstance. Specifically, replace
expect(mockWsInstance).toBeDefined() with assertions on mockWsConstructor (or
whatever jest mock wraps the ws client) to inspect
mockWsConstructor.mock.calls[0][0] equals the expected URL (e.g.,
"wss://search.example.com/graphql" when port===443 and
"wss://search.example.com:4010/graphql" when port!==443) and
mockWsConstructor.mock.calls[0][1] (or the headers object) contains the
forwarded 'sec-websocket-protocol' value when using makeMockReq with that
header; reference searchWebSocket, makeMockReq, mockGetSearchOpts,
mockWsConstructor/mockWsInstance to locate the mocks and replace the
non-verifying expect with these concrete call-argument checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant