Skip to content

Commit

Permalink
Merge pull request #461 from Shopify/v3_webhook_context
Browse files Browse the repository at this point in the history
Future flags and webhook admin context update
  • Loading branch information
paulomarg authored Oct 10, 2023
2 parents 7ce48d4 + 14fdd21 commit 82291e8
Show file tree
Hide file tree
Showing 27 changed files with 1,314 additions and 190 deletions.
67 changes: 67 additions & 0 deletions .changeset/serious-nails-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
'@shopify/shopify-app-remix': minor
---

Added support for `future` flags in the `shopifyApp` function, with a `v3_webhookContext` flag to have `authenticate.webhook` return a standard `admin` context, instead of a different type.

Apps can opt in to the new future at any time, so this is not a breaking change (yet).

<details>
<summary>See an example</summary>

Without the `v3_webhookContext` flag, `graphql` provides a `query` function that takes the query string as the `data` param.
When using variables, `data` needs to be an object containing `query` and `variables`.

```ts
import {json, ActionFunctionArgs} from '@remix-run/node';
import {authenticate} from '../shopify.server';

export async function action({request}: ActionFunctionArgs) {
const {admin} = await authenticate.webhook(request);

const response = await admin?.graphql.query<any>({
data: {
query: `#graphql
mutation populateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
}
}
}`,
variables: {input: {title: 'Product Name'}},
},
});

const productData = response?.body.data;
return json({data: productData.data});
}
```

With the `v3_webhookContext` flag enabled, `graphql` _is_ a function that takes in the query string and an optional settings object, including `variables`.

```ts
import {ActionFunctionArgs} from '@remix-run/node';
import {authenticate} from '../shopify.server';

export async function action({request}: ActionFunctionArgs) {
const {admin} = await authenticate.webhook(request);

const response = await admin?.graphql(
`#graphql
mutation populateProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
}
}
}`,
{variables: {input: {title: 'Product Name'}}},
);

const productData = await response.json();
return json({data: productData.data});
}
```

</details>
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.changeset/pre.json
coverage

packages/shopify-app-remix/docs/*
packages/shopify-app-remix/docs/**/*.json
packages/shopify-app-remix/**/*.example.tsx?
827 changes: 777 additions & 50 deletions packages/shopify-app-remix/docs/generated/generated_docs_data.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,65 @@
}
]
},
{
"id": "guide-future-flags",
"title": "Future flags",
"description": "Similarly to how [Remix approaches breaking changes](https://remix.run/docs/en/main/start/future-flags), the `@shopify/shopify-app-remix` package also uses future flags.\n\nBigger features and breaking changes are initially added behind a future flag. This means that they're disabled by default, and must be manually enabled by setting the appropriate flag in the `future` option of the `shopifyApp` function.\n\nThis allows apps to gradually adopt new features, and prepare for breaking changes and major releases ahead of time.",
"sections": [
{
"type": "Generic",
"anchorLink": "configuration",
"title": "Setting future flags",
"sectionContent": "To opt in to a feature, simply enable the appropriate flag in the `future` option of the `shopifyApp` function.\n\nOnce a flag is set, the returned `shopify` object will start using the new APIs, including using any new types. That allows apps to rely on TypeScript to use a feature regardless of a flag being enabled or not.",
"codeblock": {
"title": "/app/shopify.server.ts",
"tabs": [
{
"title": "/app/shopify.server.ts",
"language": "ts",
"code": "import {shopifyApp} from '@shopify/shopify-app-remix/server';\n\nexport const shopify = shopifyApp({\n // ...\n future: {\n unstable_newFeature: true,\n },\n});\n"
}
]
}
},
{
"type": "Generic",
"anchorLink": "unstable-apis",
"title": "Unstable APIs",
"sectionContent": "When introducing new features to the package for which we want to gather feedback, we will add them behind a future flag, starting with the `unstable_` prefix.\n\nThat allows early adopters to try them out individually, without having to install a release candidate package.\n\nWhen the feature is ready for release, the future flag will be removed and it will be available by default.\n\nIn this example, `shopify` has a new function called `newFeature`. If the future flag is disabled, TypeScript will be unaware of the new function, and the app will fail to compile if it tries to use it.",
"codeblock": {
"title": "/app/routes/*.tsx",
"tabs": [
{
"title": "/app/routes/*.tsx",
"language": "ts",
"code": "import type {LoaderFunctionArgs} from '@remix-run/node';\n\nimport {shopify} from '~/shopify.server';\n\nexport const loader = async ({request}: LoaderFunctionArgs) =&gt; {\n const result = shopify.newFeature(params);\n\n return null;\n};\n"
}
]
}
},
{
"type": "Generic",
"anchorLink": "breaking-changes",
"title": "Breaking changes",
"sectionContent": "Similarly to unstable APIs, breaking changes will be introduced behind a future flag, but the prefix will be the next major version (e.g. `v3_`).\n\nThis allows apps to prepare for the next major version ahead of time, and to gradually adopt the new APIs.\n\nWhen the next major version is released, the future flag will be removed, and the old code it changes will be removed. Apps that adopted the flag before then will continue to work the same way with no new changes."
},
{
"type": "GenericList",
"anchorLink": "flags",
"title": "Supported flags",
"sectionContent": "These are the future flags supported in the current version.",
"listItems": [
{
"name": "v3_webhookAdminContext",
"value": "",
"description": "Returns the same `admin` context (`AdminApiContext`) from `authenticate.webhook` that is returned from `authenticate.admin`.\n\nSee [authenticate.webhook](/docs/api/shopify-app-remix/authenticate/webhook#example-admin) for more details.",
"isOptional": true
}
]
}
]
},
{
"id": "shopify-app-remix",
"title": "Shopify App package for Remix",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {shopifyApp} from '@shopify/shopify-app-remix/server';

export const shopify = shopifyApp({
// ...
future: {
unstable_newFeature: true,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {LoaderFunctionArgs} from '@remix-run/node';

import {shopify} from '~/shopify.server';

export const loader = async ({request}: LoaderFunctionArgs) => {
const result = shopify.newFeature(params);

return null;
};
78 changes: 78 additions & 0 deletions packages/shopify-app-remix/docs/staticPages/future-flags.doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {LandingTemplateSchema} from '@shopify/generate-docs';

const data: LandingTemplateSchema = {
id: 'guide-future-flags',
title: 'Future flags',
description:
'Similarly to how [Remix approaches breaking changes](https://remix.run/docs/en/main/start/future-flags), the `@shopify/shopify-app-remix` package also uses future flags.' +
"\n\nBigger features and breaking changes are initially added behind a future flag. This means that they're disabled by default, and must be manually enabled by setting the appropriate flag in the `future` option of the `shopifyApp` function." +
'\n\nThis allows apps to gradually adopt new features, and prepare for breaking changes and major releases ahead of time.',
sections: [
{
type: 'Generic',
anchorLink: 'configuration',
title: 'Setting future flags',
sectionContent:
'To opt in to a feature, simply enable the appropriate flag in the `future` option of the `shopifyApp` function.' +
'\n\nOnce a flag is set, the returned `shopify` object will start using the new APIs, including using any new types. That allows apps to rely on TypeScript to use a feature regardless of a flag being enabled or not.',
codeblock: {
title: '/app/shopify.server.ts',
tabs: [
{
title: '/app/shopify.server.ts',
language: 'ts',
code: './examples/guides/future-flags/config.example.ts',
},
],
},
},
{
type: 'Generic',
anchorLink: 'unstable-apis',
title: 'Unstable APIs',
sectionContent:
'When introducing new features to the package for which we want to gather feedback, we will add them behind a future flag, starting with the `unstable_` prefix.' +
'\n\nThat allows early adopters to try them out individually, without having to install a release candidate package.' +
'\n\nWhen the feature is ready for release, the future flag will be removed and it will be available by default.' +
'\n\nIn this example, `shopify` has a new function called `newFeature`. If the future flag is disabled, TypeScript will be unaware of the new function, and the app will fail to compile if it tries to use it.',
codeblock: {
title: '/app/routes/*.tsx',
tabs: [
{
title: '/app/routes/*.tsx',
language: 'ts',
code: './examples/guides/future-flags/unstable.example.ts',
},
],
},
},
{
type: 'Generic',
anchorLink: 'breaking-changes',
title: 'Breaking changes',
sectionContent:
'Similarly to unstable APIs, breaking changes will be introduced behind a future flag, but the prefix will be the next major version (e.g. `v3_`).' +
'\n\nThis allows apps to prepare for the next major version ahead of time, and to gradually adopt the new APIs.' +
'\n\nWhen the next major version is released, the future flag will be removed, and the old code it changes will be removed. Apps that adopted the flag before then will continue to work the same way with no new changes.',
},
{
type: 'GenericList',
anchorLink: 'flags',
title: 'Supported flags',
sectionContent:
'These are the future flags supported in the current version.',
listItems: [
{
name: 'v3_webhookAdminContext',
value: '',
description:
'Returns the same `admin` context (`AdminApiContext`) from `authenticate.webhook` that is returned from `authenticate.admin`.' +
'\n\nSee [authenticate.webhook](/docs/api/shopify-app-remix/authenticate/webhook#example-admin) for more details.',
isOptional: true,
},
],
},
],
};

export default data;
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
import {SessionStorage} from '@shopify/shopify-app-session-storage';
import {LATEST_API_VERSION, LogSeverity} from '@shopify/shopify-api';
import {MemorySessionStorage} from '@shopify/shopify-app-session-storage-memory';

import {AppConfigArg} from '../config-types';
import type {AppConfigArg} from '../config-types';
import type {FutureFlags} from '../future/flags';

import {API_KEY, API_SECRET_KEY, APP_URL} from './const';

export function testConfig(
overrides: Partial<AppConfigArg> = {},
): AppConfigArg & {sessionStorage: SessionStorage} {
type DefaultedFutureFlag<
Overrides extends Partial<AppConfigArg>,
Flag extends keyof FutureFlags,
> = Overrides['future'] extends FutureFlags ? Overrides['future'][Flag] : true;

type TestConfig<Overrides extends Partial<AppConfigArg>> =
// We omit billing so we use the actual values when set, rather than the generic type
Omit<AppConfigArg, 'billing'> &
Overrides & {
// Create an object with all future flags defaulted to active to ensure our tests are updated when we introduce new flags
future: {
v3_webhookAdminContext: DefaultedFutureFlag<
Overrides,
'v3_webhookAdminContext'
>;
};
};

export function testConfig<Overrides extends Partial<AppConfigArg>>(
overrides: Overrides = {} as Overrides,
): TestConfig<Overrides> {
return {
apiKey: API_KEY,
apiSecretKey: API_SECRET_KEY,
Expand All @@ -22,5 +40,9 @@ export function testConfig(
isEmbeddedApp: true,
sessionStorage: new MemorySessionStorage(),
...overrides,
future: {
v3_webhookAdminContext: true,
...(overrides.future as Overrides['future']),
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ describe('shopifyApp', () => {

it('does not have login function when distribution is ShopifyAdmin', () => {
// GIVEN
const shopify = shopifyApp({
...testConfig(),
distribution: AppDistribution.ShopifyAdmin,
});
const shopify = shopifyApp(
testConfig({distribution: AppDistribution.ShopifyAdmin}),
);

// THEN
expect(shopify).not.toHaveProperty('login');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import {
expectAdminApiClient,
} from '../../../__test-helpers';
import {shopifyApp} from '../../..';
import {AdminApiContext} from '../../../config-types';
import {REAUTH_URL_HEADER} from '../../const';
import {AdminApiContext} from '../../../clients';

describe('admin.authenticate context', () => {
expectAdminApiClient(async () => {
Expand Down Expand Up @@ -196,7 +196,7 @@ describe('admin.authenticate context', () => {
});

async function setUpEmbeddedFlow() {
const shopify = shopifyApp({...testConfig(), restResources});
const shopify = shopifyApp(testConfig({restResources}));
const expectedSession = await setUpValidSession(shopify.sessionStorage);

const {token} = getJwt();
Expand All @@ -212,7 +212,7 @@ async function setUpEmbeddedFlow() {
}

async function setUpFetchFlow() {
const shopify = shopifyApp({...testConfig(), restResources});
const shopify = shopifyApp(testConfig({restResources}));
await setUpValidSession(shopify.sessionStorage);

const {token} = getJwt();
Expand All @@ -227,11 +227,7 @@ async function setUpFetchFlow() {
}

async function setUpNonEmbeddedFlow() {
const shopify = shopifyApp({
...testConfig(),
restResources,
isEmbeddedApp: false,
});
const shopify = shopifyApp(testConfig({restResources, isEmbeddedApp: false}));
const session = await setUpValidSession(shopify.sessionStorage);

const request = new Request(`${APP_URL}?shop=${TEST_SHOP}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ describe('authorize.admin doc request path', () => {

it('returns the context if the session is valid and the app is not embedded', async () => {
// GIVEN
const shopify = shopifyApp({...testConfig(), isEmbeddedApp: false});
const shopify = shopifyApp(testConfig({isEmbeddedApp: false}));

let testSession: Session;
testSession = await setUpValidSession(shopify.sessionStorage);
Expand Down Expand Up @@ -419,7 +419,7 @@ describe('authorize.admin doc request path', () => {

it('loads a session from the cookie from a request with no search params when not embedded', async () => {
// GIVEN
const shopify = shopifyApp({...testConfig(), isEmbeddedApp: false});
const shopify = shopifyApp(testConfig({isEmbeddedApp: false}));
const testSession = await setUpValidSession(shopify.sessionStorage);

// WHEN
Expand All @@ -438,7 +438,7 @@ describe('authorize.admin doc request path', () => {

it('returns a 400 response when no shop is available', async () => {
// GIVEN
const shopify = shopifyApp({...testConfig(), isEmbeddedApp: false});
const shopify = shopifyApp(testConfig({isEmbeddedApp: false}));
await setUpValidSession(shopify.sessionStorage);

// WHEN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,9 @@ describe('authorize.session token header path', () => {

it('returns context when session exists for non-embedded apps', async () => {
// GIVEN
const shopify = shopifyApp({
...testConfig(),
isEmbeddedApp: false,
useOnlineTokens: isOnline,
});
const shopify = shopifyApp(
testConfig({isEmbeddedApp: false, useOnlineTokens: isOnline}),
);

let testSession: Session;
testSession = await setUpValidSession(shopify.sessionStorage);
Expand Down
Loading

0 comments on commit 82291e8

Please sign in to comment.