Skip to content

Commit

Permalink
feat: add multipart subscription network adapters for Relay and urql (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
alessbell committed Nov 2, 2023
1 parent 3862f9b commit 46ab032
Show file tree
Hide file tree
Showing 12 changed files with 261 additions and 2 deletions.
28 changes: 28 additions & 0 deletions .api-reports/api-report-utilities_subscriptions_relay.md
@@ -0,0 +1,28 @@
## API Report File for "@apollo/client"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts

import type { GraphQLResponse } from 'relay-runtime';
import { Observable } from 'relay-runtime';
import type { RequestParameters } from 'relay-runtime';

// Warning: (ae-forgotten-export) The symbol "CreateMultipartSubscriptionOptions" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "OperationVariables" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export function createFetchMultipartSubscription(uri: string, { fetch: preferredFetch, headers }?: CreateMultipartSubscriptionOptions): (operation: RequestParameters, variables: OperationVariables) => Observable<GraphQLResponse>;

// @public (undocumented)
type CreateMultipartSubscriptionOptions = {
fetch?: WindowOrWorkerGlobalScope["fetch"];
headers?: Record<string, string>;
};

// @public (undocumented)
type OperationVariables = Record<string, any>;

// (No @packageDocumentation comment for this package)

```
25 changes: 25 additions & 0 deletions .api-reports/api-report-utilities_subscriptions_urql.md
@@ -0,0 +1,25 @@
## API Report File for "@apollo/client"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts

import { Observable } from 'zen-observable-ts';

// Warning: (ae-forgotten-export) The symbol "CreateMultipartSubscriptionOptions" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export function createFetchMultipartSubscription(uri: string, { fetch: preferredFetch, headers }?: CreateMultipartSubscriptionOptions): ({ query, variables, }: {
query?: string | undefined;
variables: undefined | Record<string, any>;
}) => Observable<unknown>;

// @public (undocumented)
type CreateMultipartSubscriptionOptions = {
fetch?: WindowOrWorkerGlobalScope["fetch"];
headers?: Record<string, string>;
};

// (No @packageDocumentation comment for this package)

```
46 changes: 46 additions & 0 deletions .changeset/strong-terms-perform.md
@@ -0,0 +1,46 @@
---
"@apollo/client": minor
---

Add multipart subscription network adapters for Relay and urql

### Relay

```tsx
import { createFetchMultipartSubscription } from "@apollo/client/utilities/subscriptions/relay";
import { Environment, Network, RecordSource, Store } from "relay-runtime";

const fetchMultipartSubs = createFetchMultipartSubscription(
"http://localhost:4000"
);

const network = Network.create(fetchQuery, fetchMultipartSubs);

export const RelayEnvironment = new Environment({
network,
store: new Store(new RecordSource()),
});
```

### Urql

```tsx
import { createFetchMultipartSubscription } from "@apollo/client/utilities/subscriptions/urql";
import { Client, fetchExchange, subscriptionExchange } from "@urql/core";

const url = "http://localhost:4000";

const multipartSubscriptionForwarder = createFetchMultipartSubscription(
url
);

const client = new Client({
url,
exchanges: [
fetchExchange,
subscriptionExchange({
forwardSubscription: multipartSubscriptionForwarder,
}),
],
});
```
2 changes: 1 addition & 1 deletion config/apiExtractor.ts
Expand Up @@ -30,7 +30,7 @@ map((entryPoint: { dirs: string[] }) => {
enabled: true,
...baseConfig.apiReport,
reportFileName: `api-report${
path ? "-" + path.replace("/", "_") : ""
path ? "-" + path.replace(/\//g, "_") : ""
}.md`,
},
},
Expand Down
2 changes: 2 additions & 0 deletions config/entryPoints.js
Expand Up @@ -27,6 +27,8 @@ const entryPoints = [
{ dirs: ["testing"], extensions: [".js", ".jsx"] },
{ dirs: ["testing", "core"] },
{ dirs: ["utilities"] },
{ dirs: ["utilities", "subscriptions", "relay"] },
{ dirs: ["utilities", "subscriptions", "urql"] },
{ dirs: ["utilities", "globals"], sideEffects: true },
];

Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -127,6 +127,7 @@
"@types/node-fetch": "2.6.7",
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"@types/relay-runtime": "14.1.14",
"@types/use-sync-external-store": "0.0.5",
"@typescript-eslint/eslint-plugin": "6.7.5",
"@typescript-eslint/parser": "6.7.5",
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Expand Up @@ -478,3 +478,9 @@ Array [
"newInvariantError",
]
`;

exports[`exports of public entry points @apollo/client/utilities/subscriptions/urql 1`] = `
Array [
"createFetchMultipartSubscription",
]
`;
7 changes: 7 additions & 0 deletions src/__tests__/exports.ts
Expand Up @@ -32,6 +32,7 @@ import * as testing from "../testing";
import * as testingCore from "../testing/core";
import * as utilities from "../utilities";
import * as utilitiesGlobals from "../utilities/globals";
import * as urqlUtilities from "../utilities/subscriptions/urql";

const entryPoints = require("../../config/entryPoints.js");

Expand Down Expand Up @@ -76,11 +77,17 @@ describe("exports of public entry points", () => {
check("@apollo/client/testing/core", testingCore);
check("@apollo/client/utilities", utilities);
check("@apollo/client/utilities/globals", utilitiesGlobals);
check("@apollo/client/utilities/subscriptions/urql", urqlUtilities);

it("completeness", () => {
const { join } = require("path").posix;
entryPoints.forEach((info: Record<string, any>) => {
const id = join("@apollo/client", ...info.dirs);
// We don't want to add a devDependency for relay-runtime,
// and our API extractor job is already validating its public exports,
// so we'll skip the utilities/subscriptions/relay entrypoing here
// since it errors on the `relay-runtime` import.
if (id === "@apollo/client/utilities/subscriptions/relay") return;
expect(testedIds).toContain(id);
});
});
Expand Down
60 changes: 60 additions & 0 deletions src/utilities/subscriptions/relay/index.ts
@@ -0,0 +1,60 @@
import { Observable } from "relay-runtime";
import type { RequestParameters, GraphQLResponse } from "relay-runtime";
import {
handleError,
readMultipartBody,
} from "../../../link/http/parseAndCheckHttpResponse.js";
import { maybe } from "../../index.js";
import { serializeFetchParameter } from "../../../core/index.js";

import type { OperationVariables } from "../../../core/index.js";
import type { Body } from "../../../link/http/selectHttpOptionsAndBody.js";
import { generateOptionsForMultipartSubscription } from "../shared.js";
import type { CreateMultipartSubscriptionOptions } from "../shared.js";

const backupFetch = maybe(() => fetch);

export function createFetchMultipartSubscription(
uri: string,
{ fetch: preferredFetch, headers }: CreateMultipartSubscriptionOptions = {}
) {
return function fetchMultipartSubscription(
operation: RequestParameters,
variables: OperationVariables
): Observable<GraphQLResponse> {
const body: Body = {
operationName: operation.name,
variables,
query: operation.text || "",
};
const options = generateOptionsForMultipartSubscription(headers || {});

return Observable.create((sink) => {
try {
options.body = serializeFetchParameter(body, "Payload");
} catch (parseError) {
sink.error(parseError);
}

const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch;
const observerNext = sink.next.bind(sink);

currentFetch!(uri, options)
.then((response) => {
const ctype = response.headers?.get("content-type");

if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
return readMultipartBody(response, observerNext);
}

sink.error(new Error("Expected multipart response"));
})
.then(() => {
sink.complete();
})
.catch((err: any) => {
handleError(err, sink);
});
});
};
}
21 changes: 21 additions & 0 deletions src/utilities/subscriptions/shared.ts
@@ -0,0 +1,21 @@
import { fallbackHttpConfig } from "../../link/http/selectHttpOptionsAndBody.js";

export type CreateMultipartSubscriptionOptions = {
fetch?: WindowOrWorkerGlobalScope["fetch"];
headers?: Record<string, string>;
};

export function generateOptionsForMultipartSubscription(
headers: Record<string, string>
) {
const options: { headers: Record<string, any>; body?: string } = {
...fallbackHttpConfig.options,
headers: {
...(headers || {}),
...fallbackHttpConfig.headers,
accept:
"multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json",
},
};
return options;
}
56 changes: 56 additions & 0 deletions src/utilities/subscriptions/urql/index.ts
@@ -0,0 +1,56 @@
import { Observable } from "../../index.js";
import {
handleError,
readMultipartBody,
} from "../../../link/http/parseAndCheckHttpResponse.js";
import { maybe } from "../../index.js";
import { serializeFetchParameter } from "../../../core/index.js";
import type { Body } from "../../../link/http/selectHttpOptionsAndBody.js";
import { generateOptionsForMultipartSubscription } from "../shared.js";
import type { CreateMultipartSubscriptionOptions } from "../shared.js";

const backupFetch = maybe(() => fetch);

export function createFetchMultipartSubscription(
uri: string,
{ fetch: preferredFetch, headers }: CreateMultipartSubscriptionOptions = {}
) {
return function multipartSubscriptionForwarder({
query,
variables,
}: {
query?: string;
variables: undefined | Record<string, any>;
}) {
const body: Body = { variables, query };
const options = generateOptionsForMultipartSubscription(headers || {});

return new Observable((observer) => {
try {
options.body = serializeFetchParameter(body, "Payload");
} catch (parseError) {
observer.error(parseError);
}

const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch;
const observerNext = observer.next.bind(observer);

currentFetch!(uri, options)
.then((response) => {
const ctype = response.headers?.get("content-type");

if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
return readMultipartBody(response, observerNext);
}

observer.error(new Error("Expected multipart response"));
})
.then(() => {
observer.complete();
})
.catch((err: any) => {
handleError(err, observer);
});
});
};
}

0 comments on commit 46ab032

Please sign in to comment.