diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 879bd63..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS deleted file mode 100644 index 432a4ba..0000000 --- a/docs/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @saleor/docs \ No newline at end of file diff --git a/docs/api-handlers.md b/docs/api-handlers.md deleted file mode 100644 index c49a9c1..0000000 --- a/docs/api-handlers.md +++ /dev/null @@ -1,145 +0,0 @@ -# Api Handlers - -Saleor Apps are meant to work in serverless environment, where Cloud Functions are the foundations of server-side code. - -Currently, Saleor heavily relies on Next.js, but in the future, other platforms will be supported. - -## Required handlers - -Saleor requires 2 endpoints to be available for a standalone app: - -- Manifest endpoint - Returns JSON object with app properties, like its name or permissions. [Read more](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) -- Register endpoint - During the installation process, Saleor sends `POST` request with auth token to this endpoint. [Read more](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) - -## Api handlers built-in SDK - -To hide Saleor internal logic, app-sdk provides handlers factories. They should work with minimal configuration, leaving -App creators space for domain logic. - -### Manifest handler factory - -Example usage of manifest handler in Next.js - -```typescript -// pages/api/manifest.ts - -import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; - -export default createManifestHandler({ - manifestFactory({ request, appBaseUrl }) { - return { - name: "My Saleor App", - tokenTargetUrl: `${appBaseUrl}/api/register`, - appUrl: appBaseUrl, - permissions: [], - id: "my-saleor-app", - version: "1", - }; - }, -}); -``` - -Options provided to handler factory - -```typescript -type CreateManifestHandlerOptions = { - manifestFactory(context: { appBaseUrl: string; request: NextApiRequest }): AppManifest; -}; -``` - -You can use NextApiRequest to read additional params from request. For example read Saleor version to enable or disabled some features, depending on their support. - -See [source](./src/handlers/next/create-manifest-handler.ts) for more details. See [manifest](../src/types.ts) too. - -### App register handler factory - -Example usage of app register handler in Next.js - -```typescript -// pages/api/register.ts - -import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next"; -import { UpstashAPL } from "@saleor/app-sdk/APL"; - -export default createAppRegisterHandler({ - apl: new UpstashAPL({ - restURL: "...", - restToken: "...", - }), - allowedSaleorUrls: ["https://your-saleor.saleor.cloud/graphql/"], // optional, see options below - async onRequestVerified(req, { authData, respondWithError }) { - await doSomethingAndBlockInstallation(authData.token).catch((err) => { - // Return this method to break installation flow and show error in the Dashboard - return respondWithError({ message: "Error, installation will fail" }); - }); - }, -}); -``` - -Options provided to handler factory - -```typescript -export type CreateAppRegisterHandlerOptions = { - apl: APL; - /** - * Provide your Saleor /graphql/ endpoints (or functions), - * to allow app registration only in allowed Saleor instances. - */ - allowedSaleorUrls?: Array boolean)>; - /** - * Optional - * Run right after Saleor calls this endpoint - */ - onRequestStart?( - request: Request, - context: { - authToken?: string; - saleorDomain?: string; - saleorApiUrl?: string; - respondWithError: ({ status, message }) => never; // will throw - } - ): Promise; - /** - * Optional - * Run after all security checks - */ - onRequestVerified?( - request: Request, - context: { - authData: AuthData; - respondWithError: ({ status, message }) => never; // will throw - } - ): Promise; - /** - * Optional - * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error - */ - onAuthAplSaved?( - request: Request, - context: { - authData: AuthData; - respondWithError: ({ status, message }) => never; // will throw - } - ): Promise; - /** - * Optional - * Run after APL fails to set AuthData - */ - onAplSetFailed?( - request: Request, - context: { - authData: AuthData; - error: unknown; - respondWithError: ({ status, message }) => never; // will throw - } - ): Promise; -}; -``` - -See [APL](./apl.md) for details what is Auth Persistence Layer in Saleor apps - -### Async Webhook Handler - -App SDK provides a utility that helps building (async) webhook handlers, so app can react on Saleor events. - -Read about it [here](./saleor-async-webhook.md). diff --git a/docs/apl.md b/docs/apl.md deleted file mode 100644 index a889b57..0000000 --- a/docs/apl.md +++ /dev/null @@ -1,255 +0,0 @@ -# APL - Auth Persistence Layer - -APL is an interface for managing auth data of registered Apps. Implementing it does not restrict you from choosing the right technology for your project (for example Redis, Postgres, S3, etc.), and provides a common set of operations which can be used by your application and functions provided by App SDK. - -## Available methods - -- `get: (saleorApiUrl: string) => Promise` - If the entry for given saleorApiUrl exists, returns AuthData object. - -- `set: (authData: AuthData) => Promise` - Save auth data. - -- `delete: (saleorApiUrl: string) => Promise` - Remove auth data fot the given API URL. - -- `getAll: () => Promise` - Returns all auth data available. - -- `isReady: () => Promise` - Check if persistence layer behind APL is ready. For example: database connection established - -- `isConfigured: () => Promise` - Check if persistence layer behind APL is configured. For example: env variable required by database connection - -## AuthData - -Interface containing data used for communication with the Saleor API: - -```ts -export interface AuthData { - domain: string; - token: string; - saleorApiUrl: string; - appId: string; - jwks: string; -} -``` - -- `domain` - Domain of the API -- `token` - Authorization token -- `saleorApiUrl` - Full URL to the Saleor GraphQL API -- `appID` - ID of the app assigned during the installation process -- `jwks` - JSON Web Key Set available at `https:///.well-known/jwks.json`, cached in the APL for the faster webhook validation - -## AplReadyResult & ApConfiguredResult - -Responses from `isReady()` and `isConfigured()` should match following: - -```ts -type AplReadyResult = - | { - ready: true; - } - | { - ready: false; - error: Error; - }; - -type AplConfiguredResult = - | { - configured: true; - } - | { - configured: false; - error: Error; - }; -``` - -## Example implementation - -Let's create an APL, which uses Redis for data storage: - -```ts -import { createClient } from "redis"; -import { APL, AuthData } from "@saleor/app-sdk/apl"; - -const client = createClient(); -await client.connect(); - -const redisAPL: APL = { - get: async (saleorApiUrl: string) => { - const response = await client.get(saleorApiUrl); - if (response) { - return JSON.parse(response); - } - return; - }, - set: async (authData: AuthData) => { - await client.set(authData.saleorApiUrl, JSON.stringify(authData)); - }, - delete: async (saleorApiUrl: string) => { - await client.del(saleorApiUrl); - }, - getAll: async () => { - throw new Exception("Not implemented."); - }, -}; -``` - -You'll be able to use it directly: - -```ts -import { redisAPL } from "./apl"; - -const getSavedAuthData = async () => { - await redisAPL.get("https://demo.saleor.io/graphql/"); -}; -``` - -And with middleware from the SDK: - -```ts -import { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware"; -import { redisAPL } from "./apl"; - -const handler = async (request) => { - return Response.OK({ message: "If you see this, your app is registered!" }); -}; - -// the middleware will reject request if it's domain has not been registered -export default withRegisteredSaleorDomainHeader({ apl: redisAPL })(handler); -``` - -### Using different APL depending on the environment - -Depending on the environment your app is working on, you may want to use a different APL. For example during local development you might like to use `FileAPL`, because it does not require any additional infrastructure. Deployed apps on the other hand need more robust solution. - -To handle both scenarios, initialize the proper APLs in your code based on it's environment. In your application code: - -```ts -// lib/saleorApp.ts - -import { FileAPL, UpstashAPL } from "@saleor/app-sdk/APL"; - -// Based on environment variable the app will use a different APL: -// - For local development store auth data in the `.auth-data.json`. -// - For app deployment on hosted environment like Vercel, use UpstashAPL -export const apl = process.env.APL === "upstash" ? new UpstashAPL() : new FileAPL(); -``` - -Now you can use it for in your view: - -```ts -import { SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; -import { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware"; -import { NextApiHandler } from "next"; - -// import created APL -import { apl } from "@lib/saleorApp"; - -const handler: NextApiHandler = async (request, response) => { - const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER]; - - // Get auth data - const authData = apl.get(saleorApiUrl); - - // view logic... - - return response.status(200).end(); -}; - -export default handler; -``` - -## Available APLs - -### FileAPL - -File based storage of auth data, intended for local development. Data are stored in the `.saleor-app-auth.json` file. You'll be able to develop app without additional dependencies or infrastructure. - -Please note: this APL supports single tenant only (new registrations overwrite previous ones) and should not be used on production. - -### UpstashAPL - -[Upstash](https://upstash.com) is a Redis SaaS targeting serverless applications. It's free tier is more than enough to start developing multi-tenant Saleor Apps, and credit card info is not required to create an account. - -APL implementation use REST endpoints to store the data, so no extra dependencies are required. - -To use UpstashAPL, you'll need endpoint URL and password. Both will be available at [dashboard](https://console.upstash.com/) after creating a database. Credentials can be passed to APL during it's creation: - -```ts -import { UpstashAPL } from "@saleor/app-sdk/APL"; - -const apl = new UpstashAPL({ - restToken: "XXXXXXXXXXXXXX", - restURL: "https://eu2-red-panda-00000.upstash.io", -}); -``` - -Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. - -Note: Recently Vercel introduced [KV storage](https://vercel.com/docs/storage/vercel-kv) which uses Upstash under the hood. Upstash APL will work with values provided by Vercel KV Storage too. - -### EnvAPL - -For very simple, static & single-tenant applications, you may want to just set everything once, usually in env variables and just leave it. - -There is a simple APL for this use case - `EnvAPL` - -> **Warning** -> Using this APL is highly discouraged in any production environment. -> It will break if app token is regenerated, it will not work with any flow related to updates of the app. - -#### EnvAPL usage - -##### 1. Configure app to use env apl - -```tsx -import { EnvAPL } from "@saleor/app-sdk/APL"; - -const apl = new EnvAPL({ - env: { - /** - * Map your env variables here. You dont have these values yet - */ - token: envVars.SALEOR_APP_TOKEN, - appId: envVars.SALEOR_APP_ID, - saleorApiUrl: envVars.SALEOR_API_URL, - }, - /** - * Set it to "true" - during app registration check you app logs. APL will print values you need - */ - printAuthDataOnRegister: true, -}); -``` - -##### 2. Set env variables - -After step 1, you should see your logs in similar way: - -``` -┌──────────────┬─────────────────────────────────────────────┐ -│ (index) │ Values │ -├──────────────┼─────────────────────────────────────────────┤ -│ saleorApiUrl │ 'https://my-saleor-instance.cloud/graphql/' │ -│ appId │ 'app-id' │ -│ token │ 'some-token' │ -│ jwks │ '{}' │ -│ domain │ 'my-saleor-instance.cloud' │ -└──────────────┴─────────────────────────────────────────────┘ -``` - -You need to se env variables in your provider, eg. in Vercel - you need appId, token and saleorApiUrl. -Ensure name of variables match your step 1 constructor options - -##### 3. Turn off logging and redeploy - -After env is set, you should disable printing it for security reasons - -Change constructor to include - -``` -printAuthDataOnRegister: false -``` - -Then, redeploy the app. It should be configured and work for this specific Saleor instance - -Keep in mind that - -- If you promote environment, you need to update saleorApiUrl -- If you reinstall the app, you need to repeat the process to receive new token and ID diff --git a/docs/app-bridge.md b/docs/app-bridge.md deleted file mode 100644 index 5830c9e..0000000 --- a/docs/app-bridge.md +++ /dev/null @@ -1,189 +0,0 @@ -# AppBridge - -App bridge is an interface that connects App (running inside Dashboard) with Dashboard itself. - -## Setup - -Create instance of AppBridge by running following code - -```js -import { AppBridge } from "@saleor/app-sdk/app-bridge"; - -const appBridge = new AppBridge(options); -``` - -Options object is following: - -``` -type AppBridgeOptions = { - targetDomain?: string; - saleorApiUrl?: string; - initialLocale?: LocaleCode; - autoNotifyReady?: boolean; - initialTheme?: "dark" | "light" -}; -``` - -## Access app state: - -```js -const { token, saleorApiUrl, ready, id } = appBridge.getState(); -``` - -Available state represents `AppBridgeState`: - -```typescript -type AppBridgeState = { - token?: string; - id: string; - ready: boolean; - domain: string; - path: string; - theme: ThemeType; - locale: LocaleCode; // See src/locales.ts - /** - * Full URL including protocol and path where GraphQL API is available - **/ - saleorApiUrl: string; - /** - * Versions of Saleor that app is being installed. Available from 3.15. - */ - saleorVersion?: string; - dashboardVersion?: string; - user?: { - /** - * Original permissions of the user that is using the app. - * *Not* the same permissions as the app itself. - * - * Can be used by app to check if user is authorized to perform - * domain specific actions - */ - permissions: Permission[]; - email: string; - }; - /** - * Permissions of the app itself - */ - appPermissions?: AppPermission[]; -}; -``` - -## AppBridgeProvider - -`AppBridgeProvider` and `useAppBridge` hook are exposed from app-sdk - -```tsx -// app.tsx -import { AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; - - - -; -``` - -`AppBridgeProvider` can optionally receive AppBridge instance in props, otherwise will create one automatically - -### useAppBridge hook - -In components wrapped with `AppBridgeProvider`, `useAppBridge` hook is available - -```tsx -import { useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { useEffect } from "react"; - -const MyComponent = () => { - const { appBridge, appBridgeState } = useAppBridge(); - - useEffect(() => { - appBridge?.dispatch(/* Something */); - }, [appBridge]); - - return
Current locale is: {appBridgeState?.locale}
; -}; -``` - -`appBridgeState?` and `appBridge` can be nullish, because in server side context it's not available - -## Events - -Events are messages that originate in Saleor Dashboard. AppBridge can subscribe on events and app can react on them - -### Subscribing to events - -`subscribe(eventType, callback)` - can be used to listen to particular [event type](#available-event-types). It returns an unsubscribe function, which unregisters the callback. - -Example: - -```typescript -const unsubscribe = appBridge.subscribe("handshake", (payload) => { - setToken(payload.token); // do something with event payload - - const { token } = appState.getState(); // you can also get app's current state here -}); - -// unsubscribe when callback is no longer needed -unsubscribe(); -``` - -### Unsubscribing multiple listeners - -`unsubscribeAll(eventType?)` - unregisters all callbacks of provided type. If no type was provided, it will remove all event callbacks. - -Example: - -```js -// unsubscribe from all handshake events -appBridge.unsubscribeAll("handshake"); - -// unsubscribe from all events -appBridge.unsubscribeAll(); -``` - -### Available event types - -| Event type | Description | -| :-------------- | :--------------------------------------------------------------------------- | -| `handshake` | Fired when iFrame containing the App is initialized or new token is assigned | -| `response` | Fired when Dashboard responds to an Action | -| `redirect` | Fired when Dashboard changes a subpath within the app path | -| `theme` | Fired when Dashboard changes the theme | -| `localeChanged` | Fired when Dashboard changes locale (and passes locale code in payload) | -| `tokenRefresh` | Fired when Dashboard receives a new auth token and passes it to the app | - -See [source code for detailed payload](./src/app-bridge/events.ts) - -## Actions - -Actions expose a high-level API to communicate with Saleor Dashboard. They're exported under an `actions` namespace. - -### Available methods - -**`dispatch(action)`** - dispatches an Action. Returns a promise which resolves when action is successfully completed. - -Example: - -```js -import { actions } from "@saleor/app-sdk/app-bridge"; - -const handleRedirect = async () => { - await appBridge.dispatch(actions.Redirect({ to: "/orders" })); - - console.log("Redirect complete!"); -}; - -handleRedirect(); -``` - -### Available actions - -| Action | Arguments | Description | -| :---------------------------- | :----------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Redirect` | `to` (string) - relative (inside Dashboard) or absolute URL path | | -| | `newContext` (boolean) - should open in a new browsing context | | -| `Notification` | `status` (`info` / `success` / `warning` / `error` / undefined) | | -| | `title` (string / undefined) - title of the notification | | -| | `text` (string / undefined) - content of the notification | | -| | `apiMessage` (string / undefined) - error log from api | | -| `NotifyReady` | | Inform Dashboard that AppBridge is ready | -| `UpdateRouting` | `newRoute` - current path of App to be set in URL | | -| `RequestPermissions` (>=3.15) | `permissions` - array of AppPermission, `redirectPath` - path app will be redirected to after operation ends | Ask Dashboard to give more permissions to the app. Dashboard will unmount app. After user approves or denies, Dashboard will redirect to `redirectPath`. If operation fails, `?error=REASON` will be appended | diff --git a/docs/debugging.md b/docs/debugging.md deleted file mode 100644 index a3181cb..0000000 --- a/docs/debugging.md +++ /dev/null @@ -1,54 +0,0 @@ -# Debugging - -The ability to debug is important for app developers. -App SDK provides opt-in verbose logs, that can be enabled for specific modules. - -Debug logs are implemented with [debug](https://www.npmjs.com/package/debug) package and work both in Node and Browser. - -## Enabling verbose logs - -Debug logs are aggregated within [namespaces](#namespaces). They can be enabled, disabled, or configured. - -By default, all verbose logs are disabled. - -To enable logs, use the following: - -### Enabling in Node context - -Add `DEBUG=app-sdk:{namespaces}` env variable. - -#### Example - -```shell -DEBUG=* pnpm run dev -``` - -For more details check [debug package](https://github.com/debug-js/debug#usage). - -### Enabling in Browser context - -Set `localStorage.debug = 'app-sdk:{namepsaces}'` in your frontend code. For more details see [debug package](https://github.com/debug-js/debug#browser-support). - -#### Debug in iframe - -Note, that Saleor Apps are usually hosted on a different domain than Saleor Dashboard. -That means `localStorage` must be called in the context of the App's URL, not Saleor Dashboard. - -#### Example - -```javascript -// In devtools console -localStorage.debug = "*"; -``` - -## Namespaces - -Use the namespace name to enable debug logs for each module. - -| Namespace name | Description | -| ----------------------------- | -------------------------------------------------- | -| \app-sdk:\* | Enable all | -| app-sdk:AppBridge | Enable [AppBridge](./app-bridge.md) (browser only) | -| app-sdk:Middleware:\* | Enable all middlewares (node only) | -| app-sdk:APL:\* | Enable all APLs (node only) | -| app-sdk:SaleorAsyncWebhook:\* | Enable SaleorAsyncWebhook utility | diff --git a/docs/protected-handlers.md b/docs/protected-handlers.md deleted file mode 100644 index c597334..0000000 --- a/docs/protected-handlers.md +++ /dev/null @@ -1,101 +0,0 @@ -# Protected API handlers - -The App SDK provides helpers which ensure, that incoming requests are sent from Saleor dashboard. -Example of such situation could be a change of the application configuration iframe. - -> **Warning** -> This handler only works for requests originated from frontend calls. It checks JWT token from the client, which is available -> in AppBridge. Do not call this endpoint from backend context (calling it from API function will fail) - -## How to protect the endpoint - -First, create handler for your business logic. The only difference from usual NextJS handler is an additional argument `ctx` of type `ProtectedHandlerContext`, which contains additional data related to the request: - -```typescript -export type ProtectedHandlerContext = { - baseUrl: string; // the URL your application is available - authData: AuthData; // Auth Data which can be used to communicate with the Saleor API - user: { - email: string; - userPermissions: string[]; - }; -}; -``` - -`createProtectedHandler` will check if: - -- the request has `saleor-api-url` header of the Saleor instance -- the API URL has been registered, with help of the APL -- the request has `authorization-bearer` -- the auth token is a valid JWT token created by the Saleor running on the given URL -- user has required permissions in the token - -For example purposes our endpoint will only log welcome message: - -```typescript -import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next"; -import { NextApiRequest, NextApiResponse } from "next"; -import { saleorApp } from "../../../saleor-app"; - -export const handler = async ( - req: NextApiRequest, - res: NextApiResponse, - ctx: ProtectedHandlerContext -) => { - console.log(`Greetings from ${ctx.authData.domain}`); - res.status(200); -}; - -/** - * If any of the requirements is failed, an error response will be returned. - * Otherwise, provided handler function fill be called. - * - * Last argument is optional array of permissions that will be checked. If user doesn't have them, will return 401 before handler is called - */ -export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_ORDERS"]); -``` - -To make your requests successfully communicate with the backend, `saleor-api-url` and `authorization-bearer` headers are required: - -```typescript -fetch("/api/protected", { - headers: { - /** - * Both API URL and token are available in the appBridgeState. Based on those - * headers the backend will check if the request has enough permissions to - * perform the action. - */ - "saleor-api-url": saleorApiUrl, - "authorization-bearer": token, - }, -}); -``` - -If you want to read more about `appBridgeState`, check [App Bridge](./app-bridge.md) documentation. - -### Using `useAuthenticatedFetch()` hook - -Instead of manually attaching headers with AppBridge context, you can use `useAuthenticatedFetch()` hook - -Since it requires AppBridge, it's only available in browser context. It depends on `Window` object, -so your app will break if Next.js tries to render it server-side. Hence, ensure component that uses the hook is imported with dynamic() - -Component must be within `AppBridgeProvider` to have access to the AppBridge - -```tsx -import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; -import { useEffect } from "react"; - -export const ClientComponent = () => { - const fetch = useAuthenticatedFetch(); - - useEffect(() => { - /** - * Auth headers are set up automatically, so you can just call the fetch function - */ - fetch("/api/protected"); - }, [fetch]); - - return
Your UI
; -}; -``` diff --git a/docs/protected-views.md b/docs/protected-views.md deleted file mode 100644 index f50cf29..0000000 --- a/docs/protected-views.md +++ /dev/null @@ -1,59 +0,0 @@ -# App protected views - -Most likely at least some screens of the Saleor App are meant to be visible only withing Dashboard "Apps" section. - -SDK provides a Higher Order Component that can be used to wrap Next.js pages and perform basic protection checks - -## Example usage - -```tsx -import { withAuthorization } from "@saleor/app-sdk/app-bridge"; - -const AppConfigurationPage: NextPage = () => { - return
This is app inside Dashboard
; -}; - -export default withAuthorization()(AppConfigurationPage); -``` - -This code will render default, unstyled error screens if one of following checks are not met: - -- App is rendered Server Side -- App is rendered outside iframe -- App is rendered and token can't be accessed -- App is rendered, but token is invalid - -If all conditions are successful, child Page will be rendered - -## Configuration - -Each condition can be styled with custom error React Node, by passing configuration into HOC factory - -Interface of configuration is following - -```typescript -type Props = { - unmounted?: ReactNode; - notIframe?: ReactNode; - noDashboardToken?: ReactNode; - dashboardTokenInvalid?: ReactNode; -}; -``` - -Each prop is optional, so some of them can be left with default fallback component. - -### Full example - -```tsx -import { withAuthorization } from "@saleor/app-sdk/app-bridge"; -import { ErrorPage } from "src/error-page"; - -const AppConfigurationPage: NextPage = () => { - return
This is app inside Dashboard
; -}; - -export default withAuthorization({ - unmounted: null, - notIframe: This page can be accessed from dashboard, -})(AppConfigurationPage); -``` diff --git a/docs/saleor-webhook.md b/docs/saleor-webhook.md deleted file mode 100644 index ba8f3e2..0000000 --- a/docs/saleor-webhook.md +++ /dev/null @@ -1,341 +0,0 @@ -# Saleor Webhook utilities - -Apps are usually connected via webhooks - one App sends an HTTP request to another App, informing about some event or requesting some action to be performed. - -To inform your App about events originated from Saleor, you need to expose a webhook handler, which Saleor will call with POST request. - -The App SDK provides a utility that abstracts connection details and auth, allowing developers to focus on business logic. - -Depending on the type of the webhook, you can choose one of the classes: - -- `SaleorAsyncWebhook` -- `SaleorSyncWebhook` - -## Common configuration - -Both `SaleorSyncWebhook` and `SaleorAsyncWebhook` contain similar API with little differences. - -### Constructing Webhook instance - -In Next.js pages create a page, e.g. `pages/api/webhooks/order-created.ts`. We recommend to keep webhook type in file name, which will be resolved by Next.js to route path. - -```typescript -import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; - -/** -* Default body parser must be turned off - raw body is needed to verify signature -* / -export const config = { - api: { - bodyParser: false, - }, -}; - -/** - * To be type-safe, define payload from API. This should be imported from generated GraphQL code - */ -type OrderPayload = { - id: string; -}; - -export const orderCreatedWebhook = new SaleorAsyncWebhook(options); -``` - -For `SaleorSyncWebhook` it will be similar. Create e.g. `order-calculate-taxes.ts` page and create a new instance: - -```typescript -import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; - -/** -* Default body parser must be turned off - raw body is needed to verify signature -* / -export const config = { - api: { - bodyParser: false, - }, -}; - -/** - * To be type-safe, define payload from API. This should be imported from generated GraphQL code - */ -type CalculateTaxedPayload = { - // taxes payload from subscription -}; - -export const orderCalculateTaxesWebhook = new SaleorSyncWebhook(options); -``` - -### Configuring Webhook instance - -`options` in constructor must be specified. Here are all options: - -```typescript -type Options = { - /** - * Additional webhook name, optional. - */ - name?: string; - /** - * Path to webhook. Should represent relative path from base app URL. In Next.js it will start with `api/`, e.g. `api/webhooks/order-created`. - */ - webhookPath: string; - /** - * Valid Async or Sync webhook. Constructor is statically typed so only valid Sync/Async webhooks will be allowed - */ - event: Event; - /** - * Should event be active after installation, enabled by default - */ - isActive?: boolean; - /** - * APL instance - see docs/apl/md - */ - apl: APL; - /** - * Optional callback that allows to inject custom error handling and take control of the response - */ - onError?(error: WebhookError | Error, req: NextApiRequest, res: NextApiResponse): void; - /** - * Optional callback that allows to format error message. Useful to control how much details should be returned in the response - */ - formatErrorResponse?( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ): Promise<{ - code: number; - body: object | string; - }>; - /** - * Required subscription query. Can be raw GraphQL string or the instance of query wrapped in `gql` tags - */ - query: string | ASTNode; -}; -``` - -#### Configuration examples - -##### SaleorAsyncWebhook configuration example - -```typescript -// pages/api/webhooks/order-created.ts - -/** - * To be type-safe, define payload from API. This should be imported from generated GraphQL code - */ -type OrderPayload = { - id: string; -}; - -/** -* Default body parser must be turned off - raw body is needed to verify signature -* / -export const config = { - api: { - bodyParser: false, - }, -}; - -export const orderCreatedWebhook = new SaleorAsyncWebhook({ - name: "Order Created", - webhookPath: "api/webhooks/order-created", - event: "ORDER_CREATED", - isActive: true, - apl: require("../lib/apl"), - query: ` - subscription { - event { - ... on OrderCreated { - order { - id - } - } - } - } - `, - onError(error: WebhookError | Error) { - // Can be used to e.g. trace errors - sentry.captureError(error); - }, - async formatErrorResponse( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ) { - return { - code: 400, - body: "My custom response", - }; - }, -}); -``` - -##### SyncAsyncWebhook configuration example - -```typescript -// pages/api/webhooks/order-created.ts - -/** - * To be type safe, define payload from API. This should be imported from generated graphQL code - */ -type Payload = { - taxBase: { - currency: string; - }; -}; - -/** -* Default body parser must be turned off - raw body is needed to verify signature -* / -export const config = { - api: { - bodyParser: false, - }, -}; - -export const orderCalculateTaxesWebhook = new SaleorAsyncWebhook({ - name: "Order Calculate Taxes", - webhookPath: "api/webhooks/order-created", - event: "ORDER_CALCULATE_TAXES", - isActive: true, - apl: require("../lib/apl"), - query: ` - subscription { - event { - ... on CalculateTaxes { - taxBase { - currency - } - } - } - } - `, - onError(error: WebhookError | Error) { - sentry.captureError(error); - }, - async formatErrorResponse( - error: WebhookError | Error, - req: NextApiRequest, - res: NextApiResponse - ) { - return { - code: 400, - body: "My custom response", - }; - }, -}); -``` - -### Resources - -- Check available events [here](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events) -- [Read more about APLs](./apl.md) -- [Subscription query documentation](https://docs.saleor.io/docs/3.x/developer/extending/apps/subscription-webhook-payloads) - -### Extending app manifest - -Webhooks are created in Saleor when the App is installed. Saleor uses [AppManifest](https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest) to get information about webhooks to create. -`SaleorSyncWebhook` & `SaleorAsyncWebhook` utility can generate this manifest: - -```typescript -// pages/api/manifest - -import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; - -import { orderCreatedWebhook } from "./order-created.ts"; - -export default createManifestHandler({ - manifestFactory({ appBaseUrl }) { - return { - /** - * Add one or more webhook manifests. - */ - webhooks: [orderCreatedWebhook.getWebhookManifest(appBaseUrl)], - // ...rest of your App's manifest - }; - }, -}); -``` - -Now, try to read your manifest, in default Next.js config it will be `GET localhost:3000/api/manifest`. You should see webhook configuration as part of manifest response. - -### Creating webhook domain logic - -Now, let's create a handler that will process webhook data. Let's back to handler file `pages/api/webhooks/order-created.ts`. - -```typescript -type OrderPayload = { - id: string; -}; - -export const orderCreatedWebhook = new SaleorAsyncWebhook({ - // ... your configuration -}); - -/** - * Handler has to be a default export so the Next.js will be able to use it - */ -export default orderCreatedWebhook.createHandler((req, res, context) => { - const { baseUrl, event, payload, authData } = context; - - console.log(payload.id); // type is inferred - - // Perform some domain logic - - // End with status 200 - return res.status(200).end(); -}); -``` - -### Typed sync webhook response - -Sync webhooks need to return response to Saleor, so operation can be completed. To achieve that, `SaleorAsyncWebhook` injects additional context field `buildResponse`. -It infers even from constructor and provides typed factory: - -```typescript -const webhook = new SaleorAsyncWebhook({ event: "ORDER_CALCULATE_TAXES" /* ... rest of config */ }); - -orderCreatedWebhook.createHandler((req, res, context) => { - return res.status(200).send( - context.buildResponse({ - // Fields are typed here - }) - ); -}); -``` - -### query vs subscriptionQueryAst - -Subscription query can be specified using plain string or as `ASTNode` object created by `gql` tag. - -If your project does not use any code generation for GraphQL operations, use the string. In case you are using [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which we highly recommend, you should pass a subscription as GraphQL ASTNode: - -```typescript -/** - * Subscription query, you can define it in the `.ts` file. If you write operations in separate `.graphql` files, codegen will also export them in the generated file. - */ -export const ExampleProductUpdatedSubscription = gql` - ${ProductUpdatedWebhookPayload} - subscription ExampleProductUpdated { - event { - fragment - ProductUpdatedWebhookPayload - on - ProductUpdated { - product { - id - name - } - } - } - } -`; - -export const productUpdatedWebhook = new SaleorAsyncWebhook({ - name: "Example product updated webhook", - webhookPath: "api/webhooks/saleor/product-updated", - event: "PRODUCT_UPDATED", - apl: saleorApp.apl, - query: ExampleProductUpdatedSubscription, // Or use plain string -}); -``` diff --git a/docs/settings-manager.md b/docs/settings-manager.md deleted file mode 100644 index 5d97201..0000000 --- a/docs/settings-manager.md +++ /dev/null @@ -1,107 +0,0 @@ -# Settings Manager - -Settings managers are used to persist configuration data like API keys, preferences, etc.. - -## `SettingsValue` interface - -Entries in the manager are stored using structure: - -``` - key: string; - value: string; - domain?: string; -``` - -## `DeleteMetadataArg` interface - -Argument that can be used to remove metadata via `manager.delete()` method - -It contains key and domain: - -```typescript -type DeleteMetadataArg = { - key: string; - domain: string; -}; -``` - -For values which should not be migrated during environment cloning (as private keys to payment provider), developer should use domain field to bind it to particular store instance. - -## Available methods - -- `get: (key: string, domain?: string) => Promise` -- `set: (settings: SettingsValue[] | SettingsValue) => Promise` -- `delete: (args: string | string[] | DeleteMetadataArg | DeleteMetadataArg[]) => Promise` - -Warning: delete method can throw, if instance of SettingsManager was not configured with proper mutation in constructor. - -# MetadataManager - -Default manager used by app template. Use app metadata as storage. Since app developer can use any GraphQL client, constructor must be parametrized with fetch and mutate functions: - -```ts -import { MetadataEntry } from "@saleor/app-sdk/settings-manager"; -import { Client } from "urql"; - -import { - FetchAppDetailsDocument, - FetchAppDetailsQuery, - UpdateAppMetadataDocument, - DeleteMetadataDocument, -} from "../generated/graphql"; - -export async function fetchAllMetadata(client: Client): Promise { - const { error, data } = await client - .query(FetchAppDetailsDocument, {}) - .toPromise(); - - return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; -} - -export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { - const { error: mutationError, data: mutationData } = await client - .mutation(UpdateAppMetadataDocument, { - id: appId, - input: metadata, - }) - .toPromise(); - - return ( - mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({ - key: md.key, - value: md.value, - })) || [] - ); -} - -export async function deleteMetadata(client: Client, keys: string[]) { - return client.mutation(DeleteMetadataDocument, { keys }).toPromise(); -} -``` - -And create MetadataManager instance: - -```ts -const settings = new MetadataManager({ - fetchMetadata: () => fetchAllMetadata(client), - mutateMetadata: (md) => mutateMetadata(client, md), - deleteMetadata: (keys) => deleteMetadata(client, keys), -}); -``` - -# EncryptedMetadataManager - -This manager encrypts add the layer of encryption for all the stored data. -To operate correctly, the encryption key needs to be passed to the constructor: - -```ts -new EncryptedMetadataManager({ - encryptionKey: process.env.SECRET_KEY, // secrets should be saved in the environment variables, never in the source code - fetchMetadata: () => fetchAllMetadata(client), - mutateMetadata: (metadata) => mutateMetadata(client, metadata), - deleteMetadata: (keys) => deleteMetadata(client, keys), -}); -``` - -> **Warning** -> If encryption key won't be passed, the application will exit.