Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #1209 from Shopify/ml-api-clients-stream-response
Browse files Browse the repository at this point in the history
[Feature][GraphQL Client | SFAPI Client] Add support for streamed API responses
  • Loading branch information
melissaluu authored Feb 16, 2024
2 parents 185dab6 + 9df4bac commit 6f8ada7
Show file tree
Hide file tree
Showing 22 changed files with 3,453 additions and 1,237 deletions.
7 changes: 7 additions & 0 deletions .changeset/orange-rats-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@shopify/storefront-api-client": minor
"@shopify/graphql-client": minor
"@shopify/admin-api-client": patch
---

Add new `requestStream()` function to support streamed responses from Storefront API
10 changes: 5 additions & 5 deletions packages/admin-api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const {data, errors, extensions} = await client.request(operation, {
| Property | Type | Description |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| config | [`AdminApiClientConfig`](#adminapiclientconfig-properties) | Configuration for the client |
| getHeaders | `(headers?: {[key: string]: string}) => {[key: string]: string` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. |
| getHeaders | `(headers?: Record<string, string \| string[]>) => Record<string, string \| string[]>` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. |
| getApiUrl | `(apiVersion?: string) => string` | Returns the shop specific API url. If an API version is provided, the returned URL will include the provided version, else the URL will include the API version set at client initialization. |
| fetch | `(operation: string, options?:`[`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties)`) => Promise<Response>` | Fetches data from Admin API using the provided GQL `operation` string and [`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties) object and returns the network response. |
| request | `<TData>(operation: string, options?:`[`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties)`) => Promise<`[`ClientResponse<TData>`](#clientresponsetdata)`>` | Requests data from Admin API using the provided GQL `operation` string and [`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties) object and returns a normalized response object. |
Expand All @@ -77,17 +77,17 @@ const {data, errors, extensions} = await client.request(operation, {
| storeDomain | `string` | The `myshopify.com` domain |
| apiVersion | `string` | The Admin API version to use in the API request |
| accessToken | `string` | The provided public access token. If `privateAccessToken` was provided, `publicAccessToken` will not be available. |
| headers | `{[key: string]: string}` | The headers generated by the client during initialization |
| headers | `Record<string, string \| string[]>` | The headers generated by the client during initialization |
| apiUrl | `string` | The API URL generated from the provided store domain and api version |
| retries? | `number` | The number of retries the client will attempt when the API responds with a `Too Many Requests (429)` or `Service Unavailable (503)` response |

### `ApiClientRequestOptions` properties

| Name | Type | Description |
| -------------- | ------------------------ | ---------------------------------------------------- |
| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation |
| variables? | `Record<string, any>` | Variable values needed in the graphQL operation |
| apiVersion? | `string` | The Admin API version to use in the API request |
| headers? | `{[key: string]: string}`| Customized headers to be included in the API request |
| headers? | `Record<string, string \| string[]>`| Customized headers to be included in the API request |
| retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`. |

### `ClientResponse<TData>`
Expand All @@ -96,7 +96,7 @@ const {data, errors, extensions} = await client.request(operation, {
| ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data? | `TData \| any` | Data returned from the Admin API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. |
| errors? | [`ResponseErrors`](#responseerrors) | Error object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. |
| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. |
| extensions? | `Record<string, any>` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. |

### `ResponseErrors`

Expand Down
1 change: 1 addition & 0 deletions packages/admin-api-client/src/graphql/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("Admin API Client", () => {
},
fetch: jest.fn(),
request: jest.fn(),
requestStream: jest.fn(),
};

beforeEach(() => {
Expand Down
59 changes: 51 additions & 8 deletions packages/graphql-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const client = createGraphQLClient({
| Property | Type | Description |
| -------- | ------------------------ | ---------------------------------- |
| url | `string` | The GraphQL API URL |
| headers | `{[key: string]: string}` | Headers to be included in requests |
| headers | `Record<string, string \| string[]>` | Headers to be included in requests |
| retries? | `number` | The number of HTTP request retries if the request was abandoned or the server responded with a `Too Many Requests (429)` or `Service Unavailable (503)` response. Default value is `0`. Maximum value is `3`. |
| customFetchApi? | `(url: string, init?: {method?: string, headers?: HeaderInit, body?: string}) => Promise<Response>` | A replacement `fetch` function that will be used in all client network requests. By default, the client uses `window.fetch()`. |
| logger? | `(logContent: `[`HTTPResponseLog`](#httpresponselog)`\|`[`HTTPRetryLog`](#httpretrylog)`) => void` | A logger function that accepts [log content objects](#log-content-types). This logger will be called in certain conditions with contextual information. |
Expand All @@ -77,17 +77,18 @@ const client = createGraphQLClient({

| Property | Type | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| config | `{url: string, headers: {[key: string]: string}, retries: number}` | Configuration for the client |
| config | `{url: string, headers: Record<string, string \| string[]>, retries: number}` | Configuration for the client |
| fetch | `(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise<Response>` | Fetches data from the GraphQL API using the provided GQL operation string and [`RequestOptions`](#requestoptions-properties) object and returns the network response |
| request | `<TData>(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise<`[`ClientResponse<TData>`](#ClientResponsetdata)`>` | Fetches data from the GraphQL API using the provided GQL operation string and [`RequestOptions`](#requestoptions-properties) object and returns a [normalized response object](#clientresponsetdata) |
| requestStream | `<TData>(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise <AsyncIterator<`[`ClientStreamResponse<TData>`](#clientstreamresponsetdata)`>>` | Fetches GQL operations that can result in a streamed response from the API. The function returns an async iterator and the iterator will return [normalized stream response objects](#clientstreamresponsetdata) as data becomes available through the stream. |

## `RequestOptions` properties

| Name | Type | Description |
| ---------- | --------------------- | ---------------------------------------------------------------- |
| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation |
| variables? | `Record<string, any>` | Variable values needed in the graphQL operation |
| url? | `string` | Alternative request API URL |
| headers? | `{[key: string]: string}` | Additional and/or replacement headers to be used in the request |
| headers? | `Record<string, string \| string[]>` | Additional and/or replacement headers to be used in the request |
| retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`.|

## `ClientResponse<TData>`
Expand All @@ -96,7 +97,16 @@ const client = createGraphQLClient({
| ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data? | `TData \| any` | Data returned from the GraphQL API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. |
| errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. |
| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. |
| extensions? | `Record<string, any>` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. |

## `ClientStreamResponse<TData>`

| Name | Type | Description |
| ----------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data? | `Partial<TData> \| any` | Currently available data returned from the GraphQL API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. |
| errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. |
| extensions? | `Record<string, any>` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. |
| hasNext | `boolean` | Flag to indicate whether the response stream has more incoming data |

## `ResponseErrors`

Expand All @@ -107,7 +117,6 @@ const client = createGraphQLClient({
| graphQLErrors? | `any[]` | The GraphQL API errors returned by the server |
| response? | `Response` | The raw response object from the network fetch call |


## Usage examples

### Query for a product
Expand All @@ -130,6 +139,32 @@ const {data, errors, extensions} = await client.request(productQuery, {
});
```

### Query for product info using the `@defer` directive

```typescript
const productQuery = `
query ProductQuery($handle: String) {
product(handle: $handle) {
id
handle
... @defer(label: "deferredFields") {
title
description
}
}
}
`;

const responseStream = await client.requestStream(productQuery, {
variables: {handle: 'sample-product'},
});

// await available data from the async iterator
for await (const response of responseStream) {
const {data, errors, extensions, hasNext} = response;
}
```

### Add additional custom headers to the API request

```typescript
Expand Down Expand Up @@ -192,14 +227,15 @@ const {data, errors, extensions} = await client.request(shopQuery, {
});
```

### Provide GQL query type to `client.request()`
### Provide GQL query type to `client.request()` and `client.requestStream()`

```typescript
import {print} from 'graphql/language';

// GQL operation types are usually auto generated during the application build
import {CollectionQuery} from 'types/appTypes';
import {CollectionQuery, CollectionDeferredQuery} from 'types/appTypes';
import collectionQuery from './collectionQuery.graphql';
import collectionDeferredQuery from './collectionDeferredQuery.graphql';

const {data, errors, extensions} = await client.request<CollectionQuery>(
print(collectionQuery),
Expand All @@ -209,6 +245,13 @@ const {data, errors, extensions} = await client.request<CollectionQuery>(
},
}
);

const responseStream = await client.requestStream<CollectionDeferredQuery>(
print(collectionDeferredQuery),
{
variables: {handle: 'sample-collection'},
}
);
```

### Using `client.fetch()` to get API data
Expand Down
11 changes: 11 additions & 0 deletions packages/graphql-client/src/api-client-utilities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Headers,
ClientResponse,
FetchResponseBody,
ClientStreamIterator,
} from "../graphql-client/types";

import {
Expand Down Expand Up @@ -81,6 +82,16 @@ export type ApiClientRequest<Operations extends AllOperations = AllOperations> =
>
>;

export type ApiClientRequestStream<
Operations extends AllOperations = AllOperations,
> = <TData = undefined, Operation extends keyof Operations = string>(
...params: ApiClientRequestParams<Operation, Operations>
) => Promise<
ClientStreamIterator<
TData extends undefined ? ReturnData<Operation, Operations> : TData
>
>;

export interface ApiClient<
TClientConfig extends ApiClientConfig = ApiClientConfig,
Operations extends AllOperations = AllOperations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ export function validateDomainAndGetStoreUrl({

const trimmedDomain = storeDomain.trim();

const protocolUrl =
trimmedDomain.startsWith("http:") || trimmedDomain.startsWith("https:")
? trimmedDomain
: `https://${trimmedDomain}`;
const protocolUrl = trimmedDomain.match(/^https?:/)
? trimmedDomain
: `https://${trimmedDomain}`;

const url = new URL(protocolUrl);
url.protocol = "https";
Expand Down
Loading

0 comments on commit 6f8ada7

Please sign in to comment.