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

Commit

Permalink
Add code for streamed API responses
Browse files Browse the repository at this point in the history
Add unit tests for responseStream() related functionalities and updated error messages

Add tests for requestStream and refactor tests for efficiency

Update README to include requestStream info

Add requestStream() to Storefront API Client

Add tests for requestStream in Storefront Api Client

Reorganize and split graphql-client tests

Refactor unit tests and update api version validation

Fix rebase issues

Add requestStream info to SFAPI README

Rebase to main and fix linting and test errors

Update READMEs for correct header type

Update ClientStreamResponse.data type

Update client to support 2022 stream response format

Fix Admin API client README

Add changelog entry

use .reduce instead of Object.fromEntries for browser support

Update tests to test array style custom headers

Refactor domain validation logic for clarity

Address PR review comments and consolidated a few tests

As per PR review comments, reorg the code to make it more readable

Clean up chunked response data types

Address PR review comments in utilities
  • Loading branch information
melissaluu committed Feb 16, 2024
1 parent 185dab6 commit 9df4bac
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 9df4bac

Please sign in to comment.