load
15
.changeset/config.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": true,
|
||||
"tag": true
|
||||
}
|
||||
}
|
||||
274
.dependency-cruiser.js
Normal file
@@ -0,0 +1,274 @@
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
export default {
|
||||
forbidden: [
|
||||
/* rules from the 'recommended' preset: */
|
||||
{
|
||||
name: "no-circular",
|
||||
severity: "warn",
|
||||
comment:
|
||||
"This dependency is part of a circular relationship. You might want to revise " +
|
||||
"your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
|
||||
from: {},
|
||||
to: {
|
||||
circular: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-orphans",
|
||||
comment:
|
||||
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
|
||||
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
|
||||
"add an exception for it in your dependency-cruiser configuration. By default " +
|
||||
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
|
||||
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
|
||||
severity: "warn",
|
||||
from: {
|
||||
orphan: true,
|
||||
pathNot: [
|
||||
"(^|/)\\.[^/]+\\.(js|jsx|cjs|mjs|ts|tsx|cts|ctsx|mts|mtsx|json)$", // dot files
|
||||
"\\.d\\.ts$", // TypeScript declaration files
|
||||
"(^|/)tsconfig\\.json$", // TypeScript config
|
||||
"(^|/)(babel|webpack)\\.config\\.(js|jsx|cjs|mjs|ts|tsx|cts|ctsx|mts|mtsx|json)$", // other configs
|
||||
],
|
||||
},
|
||||
to: {},
|
||||
},
|
||||
{
|
||||
name: "no-deprecated-core",
|
||||
comment:
|
||||
"A module depends on a node core module that has been deprecated. Find an alternative - these are " +
|
||||
"bound to exist - node doesn't deprecate lightly.",
|
||||
severity: "warn",
|
||||
from: {},
|
||||
to: {
|
||||
dependencyTypes: ["core"],
|
||||
path: [
|
||||
"^(v8/tools/codemap)$",
|
||||
"^(v8/tools/consarray)$",
|
||||
"^(v8/tools/csvparser)$",
|
||||
"^(v8/tools/logreader)$",
|
||||
"^(v8/tools/profile_view)$",
|
||||
"^(v8/tools/profile)$",
|
||||
"^(v8/tools/SourceMap)$",
|
||||
"^(v8/tools/splaytree)$",
|
||||
"^(v8/tools/tickprocessor-driver)$",
|
||||
"^(v8/tools/tickprocessor)$",
|
||||
"^(node-inspect/lib/_inspect)$",
|
||||
"^(node-inspect/lib/internal/inspect_client)$",
|
||||
"^(node-inspect/lib/internal/inspect_repl)$",
|
||||
"^(async_hooks)$",
|
||||
"^(punycode)$",
|
||||
"^(domain)$",
|
||||
"^(constants)$",
|
||||
"^(sys)$",
|
||||
"^(_linklist)$",
|
||||
"^(_stream_wrap)$",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not-to-deprecated",
|
||||
comment:
|
||||
"This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
|
||||
"version of that module, or find an alternative. Deprecated modules are a security risk.",
|
||||
severity: "warn",
|
||||
from: {},
|
||||
to: {
|
||||
dependencyTypes: ["deprecated"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-non-package-json",
|
||||
severity: "error",
|
||||
comment:
|
||||
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
|
||||
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
|
||||
"available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
|
||||
"in your package.json.",
|
||||
from: {},
|
||||
to: {
|
||||
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not-to-unresolvable",
|
||||
comment:
|
||||
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
|
||||
"module: add it to your package.json. In all other cases you likely already know what to do.",
|
||||
severity: "error",
|
||||
from: {
|
||||
pathNot: "generated/graphql.ts",
|
||||
},
|
||||
to: {
|
||||
pathNot: "@saleor/app-sdk/types",
|
||||
couldNotResolve: true,
|
||||
dependencyTypesNot: ["type-only"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-duplicate-dep-types",
|
||||
comment:
|
||||
"Likely this module depends on an external ('npm') package that occurs more than once " +
|
||||
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
|
||||
"maintenance problems later on.",
|
||||
severity: "warn",
|
||||
from: {},
|
||||
to: {
|
||||
moreThanOneDependencyType: true,
|
||||
// as it's pretty common to have a type import be a type only import
|
||||
// _and_ (e.g.) a devDependency - don't consider type-only dependency
|
||||
// types for this rule
|
||||
dependencyTypesNot: ["type-only"],
|
||||
},
|
||||
},
|
||||
|
||||
/* rules you might want to tweak for your specific situation: */
|
||||
{
|
||||
name: "not-to-spec",
|
||||
comment:
|
||||
"This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
|
||||
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
|
||||
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
|
||||
severity: "error",
|
||||
from: {
|
||||
pathNot:
|
||||
"(saleor-app\\.ts$)|(__tests__/\\.*)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md)$)",
|
||||
},
|
||||
to: {
|
||||
path: "(__tests__/\\.*)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md)$)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not-to-dev-dep",
|
||||
severity: "error",
|
||||
comment:
|
||||
"This module depends on an npm package from the 'devDependencies' section of your " +
|
||||
"package.json. It looks like something that ships to production, though. To prevent problems " +
|
||||
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
|
||||
"section of your package.json. If this module is development only - add it to the " +
|
||||
"from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
|
||||
from: {
|
||||
path: "^(src)",
|
||||
pathNot:
|
||||
"(src/schemas/compiler.mts)|(.*/__tests__/.*)|(setup-tests\\.ts$)|(\\.(spec|test)\\.(js|jsx|mjs|cjs|ts|tsx|cts|ctsx|mts|mtsx\\.md))$",
|
||||
},
|
||||
to: {
|
||||
dependencyTypes: ["npm-dev"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "optional-deps-used",
|
||||
severity: "info",
|
||||
comment:
|
||||
"This module depends on an npm package that is declared as an optional dependency " +
|
||||
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
|
||||
"If you're using an optional dependency here by design - add an exception to your" +
|
||||
"dependency-cruiser configuration.",
|
||||
from: {},
|
||||
to: {
|
||||
dependencyTypes: ["npm-optional"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "peer-deps-used",
|
||||
comment:
|
||||
"This module depends on an npm package that is declared as a peer dependency " +
|
||||
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
|
||||
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
|
||||
"add an exception to your dependency-cruiser configuration.",
|
||||
severity: "warn",
|
||||
from: {},
|
||||
to: {
|
||||
dependencyTypes: ["npm-peer"],
|
||||
},
|
||||
},
|
||||
],
|
||||
options: {
|
||||
/* conditions specifying which files not to follow further when encountered:
|
||||
- path: a regular expression to match
|
||||
- dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/master/doc/rules-reference.md#dependencytypes-and-dependencytypesnot
|
||||
for a complete list
|
||||
*/
|
||||
doNotFollow: {
|
||||
path: "node_modules",
|
||||
},
|
||||
|
||||
/* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation
|
||||
true: also detect dependencies that only exist before typescript-to-javascript compilation
|
||||
"specify": for each dependency identify whether it only exists before compilation or also after
|
||||
*/
|
||||
tsPreCompilationDeps: true,
|
||||
|
||||
/* TypeScript project file ('tsconfig.json') to use for
|
||||
(1) compilation and
|
||||
(2) resolution (e.g. with the paths property)
|
||||
|
||||
The (optional) fileName attribute specifies which file to take (relative to
|
||||
dependency-cruiser's current working directory). When not provided
|
||||
defaults to './tsconfig.json'.
|
||||
*/
|
||||
tsConfig: {
|
||||
fileName: "tsconfig.json",
|
||||
},
|
||||
|
||||
/* options to pass on to enhanced-resolve, the package dependency-cruiser
|
||||
uses to resolve module references to disk. You can set most of these
|
||||
options in a webpack.conf.js - this section is here for those
|
||||
projects that don't have a separate webpack config file.
|
||||
|
||||
Note: settings in webpack.conf.js override the ones specified here.
|
||||
*/
|
||||
enhancedResolveOptions: {
|
||||
/* List of strings to consider as 'exports' fields in package.json. Use
|
||||
['exports'] when you use packages that use such a field and your environment
|
||||
supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack).
|
||||
|
||||
If you have an `exportsFields` attribute in your webpack config, that one
|
||||
will have precedence over the one specified here.
|
||||
*/
|
||||
exportsFields: ["exports"],
|
||||
/* List of conditions to check for in the exports field. e.g. use ['imports']
|
||||
if you're only interested in exposed es6 modules, ['require'] for commonjs,
|
||||
or all conditions at once `(['import', 'require', 'node', 'default']`)
|
||||
if anything goes for you. Only works when the 'exportsFields' array is
|
||||
non-empty.
|
||||
|
||||
If you have a 'conditionNames' attribute in your webpack config, that one will
|
||||
have precedence over the one specified here.
|
||||
*/
|
||||
conditionNames: ["import", "require", "node", "default"],
|
||||
/*
|
||||
If your TypeScript project makes use of types specified in 'types'
|
||||
fields in package.jsons of external dependencies, specify "types"
|
||||
in addition to "main" in here, so enhanced-resolve (the resolver
|
||||
dependency-cruiser uses) knows to also look there. You can also do
|
||||
this if you're not sure, but still use TypeScript. In a future version
|
||||
of dependency-cruiser this will likely become the default.
|
||||
*/
|
||||
mainFields: ["module", "main", "types"],
|
||||
},
|
||||
reporterOptions: {
|
||||
dot: {
|
||||
/* pattern of modules that can be consolidated in the detailed
|
||||
graphical dependency graph. The default pattern in this configuration
|
||||
collapses everything in node_modules to one folder deep so you see
|
||||
the external modules, but not the innards your app depends upon.
|
||||
*/
|
||||
collapsePattern: "node_modules/(@[^/]+/[^/]+|[^/]+)",
|
||||
},
|
||||
archi: {
|
||||
/* pattern of modules that can be consolidated in the high level
|
||||
graphical dependency graph. If you use the high level graphical
|
||||
dependency graph reporter (`archi`) you probably want to tweak
|
||||
this collapsePattern to your situation.
|
||||
*/
|
||||
collapsePattern:
|
||||
"^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)",
|
||||
},
|
||||
text: {
|
||||
highlightFocused: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// generated: dependency-cruiser@12.11.0 on 2023-03-25T15:14:42.761Z
|
||||
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
|
||||
APP_DEBUG=info
|
||||
SECRET_KEY=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
|
||||
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
|
||||
APP_DEBUG=info
|
||||
SECRET_KEY=aaaaaaaa
|
||||
14
.env.test
Normal file
@@ -0,0 +1,14 @@
|
||||
APP_DEBUG=error
|
||||
SECRET_KEY=aaaaaaaa
|
||||
|
||||
TEST_SALEOR_API_URL="https://saleor.localhost:8080/graphql/"
|
||||
TEST_SALEOR_JWKS='{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"1dBwlEgrHxAM64KH-pupx-VBeISR4Jkh6NLIDStGarXQkLECSMrmGd8eIzKZ4vSvOF0zxfE7zFVRTm4MzFWBxrr0YWoWQRDIKteEcUTDerfVQ0NbUPZAz6siIg4X-qI1rWWu85nkjWAFOax6ociMh9nG46pekueATkjd6lxdrkcjLUgRMm_CRoIQL8Ad7tYt67Ua8gvIkXcF5pV6Cr7ukjySnOzavP25k6XkAgmNEI_Nl60rL4a0imoBZUXRoWCXmqpPFNH5HtyHj7FxFEEoqPZ_-4NIT-eTMgrbqC-lLixjQuyZIZcZHXiC0CBcw-cdPSxsqqcxagcSiBrzoX9idw\", \"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}'
|
||||
|
||||
# Remaining TEST_* vars must be configured separately
|
||||
TEST_SALEOR_APP_TOKEN=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_SALEOR_APP_TOKEN
|
||||
TEST_SALEOR_APP_ID=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_SALEOR_APP_ID
|
||||
TEST_PAYMENT_APP_SECRET_KEY=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_SECRET_KEY
|
||||
TEST_PAYMENT_APP_PUBLISHABLE_KEY=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_PUBLISHABLE_KEY
|
||||
TEST_PAYMENT_APP_WEBHOOK_SECRET=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_WEBHOOK_SECRET
|
||||
TEST_PAYMENT_APP_WEBHOOK_ID=op://Shop-ex/saleor-app-payment-stripe_TEST_ENVS/TEST_PAYMENT_APP_WEBHOOK_ID
|
||||
# When you add more variables remember to add them in .github/workflows/main.yml and src/__tests__/test-env.mjs
|
||||
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
generated/
|
||||
src/schemas/**/*.mjs
|
||||
src/schemas/**/*.mts
|
||||
150
.eslintrc
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/eslintrc.json",
|
||||
"plugins": ["@typescript-eslint", "require-form-method", "node", "vitest"],
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"prettier",
|
||||
"plugin:vitest/recommended",
|
||||
"plugin:@saleor/saleor-app/recommended"
|
||||
],
|
||||
"rules": {
|
||||
// use double quotes, allow template strings
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
|
||||
// sort imports
|
||||
"import/order": "error",
|
||||
|
||||
// no let exports
|
||||
"import/no-mutable-exports": "error",
|
||||
|
||||
"import/no-cycle": "error",
|
||||
"import/no-default-export": "error",
|
||||
|
||||
// prevent default import from Sentry because it doesn't work 😬
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "ImportDeclaration[source.value='@sentry/nextjs'] > ImportDefaultSpecifier",
|
||||
"message": "Use `import * as Sentry from '@sentry/nextjs';`"
|
||||
}
|
||||
],
|
||||
|
||||
// allow {} even though it's unsafe but comes handy
|
||||
"@typescript-eslint/ban-types": [
|
||||
"error",
|
||||
{
|
||||
"types": {
|
||||
"{}": false
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports",
|
||||
"disallowTypeAnnotations": false
|
||||
}
|
||||
],
|
||||
|
||||
"import/no-duplicates": ["error", { "prefer-inline": true }],
|
||||
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@/__tests__/test-env.mjs"],
|
||||
"message": "Test envs cannot be imported into application code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"node/no-process-env": ["error"],
|
||||
|
||||
// false negatives
|
||||
"import/namespace": ["off"],
|
||||
|
||||
// we allow empty interfaces
|
||||
"no-empty-pattern": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
|
||||
// we allow empty functions
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
|
||||
// we sometimes use async functions that don't await anything
|
||||
"@typescript-eslint/require-await": "off",
|
||||
|
||||
// make sure to `await` inside try…catch
|
||||
"@typescript-eslint/return-await": ["error", "in-try-catch"],
|
||||
|
||||
// allow unused vars prefixed with `_`
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
|
||||
],
|
||||
|
||||
// numbers and booleans are fine in template strings
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{ "allowNumber": true, "allowBoolean": true }
|
||||
],
|
||||
|
||||
// for security reasons, always require forms to provide method="post"
|
||||
"require-form-method/require-form-method": "error",
|
||||
|
||||
// for security reasons, always require buttons to provide type="button" ("submit" on rare occasions)
|
||||
"react/button-has-type": ["error", { "button": true, "submit": true, "reset": false }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.test.tsx", "*.test.ts", "**/__tests__/**/*.ts?(x)"],
|
||||
"rules": {
|
||||
// let's make our lives easier in tests
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
|
||||
// allow imports from testEnv
|
||||
"@typescript-eslint/no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// https://github.com/testing-library/eslint-plugin-testing-library#run-the-plugin-only-against-test-files
|
||||
"files": ["**/__tests__/**/*.[jt]sx", "**/?(*.)+(spec|test).[jt]sx"],
|
||||
"extends": ["plugin:testing-library/react"]
|
||||
},
|
||||
{
|
||||
// We allow process.env access in env.mjs and testEnv.mjs
|
||||
"files": ["**/env.mjs", "**/test-env.mjs", "next.config.mjs"],
|
||||
"rules": {
|
||||
"node/no-process-env": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/pages/**/*.ts?(x)"],
|
||||
"rules": {
|
||||
"import/no-default-export": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.cjs", "*.cts"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-var-requires": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["*.js", "*.jsx"]
|
||||
}
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.har linguist-generated=true
|
||||
48
.graphqlrc.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
schema: graphql/schema.graphql
|
||||
documents: [graphql/**/*.graphql, src/migrations/**/*.graphql, src/**/*.ts, src/**/*.tsx]
|
||||
extensions:
|
||||
codegen:
|
||||
overwrite: true
|
||||
generates:
|
||||
generated/graphql.ts:
|
||||
hooks:
|
||||
afterAllFileWrite:
|
||||
- prettier --write
|
||||
config:
|
||||
immutableTypes: true
|
||||
strictScalars: true
|
||||
defaultScalarType: "unknown"
|
||||
useTypeImports: true
|
||||
dedupeFragments: true
|
||||
skipTypename: true
|
||||
scalars:
|
||||
_Any: "unknown"
|
||||
Date: "string"
|
||||
DateTime: "string"
|
||||
Decimal: "number"
|
||||
Minute: "number"
|
||||
GenericScalar: "JSONValue"
|
||||
JSON: "JSONValue"
|
||||
JSONString: "string"
|
||||
Metadata: "Record<string, string>"
|
||||
PositiveDecimal: "number"
|
||||
Upload: "unknown"
|
||||
UUID: "string"
|
||||
WeightScalar: "number"
|
||||
plugins:
|
||||
- add:
|
||||
content: |
|
||||
/* c8 ignore start */
|
||||
import type { JSONValue } from '@/types';
|
||||
- typescript
|
||||
- typescript-operations:
|
||||
skipTypeNameForRoot: true
|
||||
exportFragmentSpreadSubTypes: true
|
||||
- urql-introspection
|
||||
- typescript-urql:
|
||||
documentVariablePrefix: "Untyped"
|
||||
fragmentVariablePrefix: "Untyped"
|
||||
- typed-document-node
|
||||
generated/schema.graphql:
|
||||
plugins:
|
||||
- schema-ast
|
||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
||||
13
.lintstagedrc.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// https://nextjs.org/docs/basic-features/eslint#lint-staged
|
||||
|
||||
import path from "path";
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`next lint --fix --file ${filenames
|
||||
.map((f) => path.relative(process.cwd(), f))
|
||||
.join(" --file ")}`;
|
||||
|
||||
export default {
|
||||
"*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}": [buildEslintCommand],
|
||||
"*.*": "prettier --write --ignore-unknown",
|
||||
};
|
||||
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.next
|
||||
pnpm-lock.yaml
|
||||
graphql/schema.graphql
|
||||
*.har
|
||||
src/schemas/**/*.ts
|
||||
src/schemas/**/*.mts
|
||||
src/schemas/**/*.cts
|
||||
src/schemas/**/*.js
|
||||
src/schemas/**/*.mjs
|
||||
src/schemas/**/*.cjs
|
||||
coverage/**
|
||||
5
.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"graphql.vscode-graphql",
|
||||
"graphql.vscode-graphql-syntax",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
16
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${workspaceFolder}/src/pages/api/test.ts",
|
||||
"outFiles": ["${workspaceFolder}/**/*.js"]
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"eslint.run": "onSave",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "always"
|
||||
},
|
||||
"graphql-config.load.rootDir": "./",
|
||||
"graphql-config.load.configName": "graphql"
|
||||
}
|
||||
40
CHANGELOG.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# saleor-app-payment-stripe
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c69f6b8: Added "ALLOWED_DOMAIN_PATTERN" env that can be used to allow/disallow specific Saleor instances
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6817fbe: Fixed blank configuration screen; Stripe API is now on the latest version
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e47cd4e: Bumped zod from 3.22.2 to 3.22.3
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- be1419f: Update Sentry to 7.77.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a1df783: Add webhook ID to the summary view
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1df783: Add simple skeletons
|
||||
- 4d8b58b: Add more logs
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- baf1c0e: Initial release
|
||||
3
CODEOWNERS
Normal file
@@ -0,0 +1,3 @@
|
||||
* @saleor/apps-guild
|
||||
/.github/ @saleor/SRE
|
||||
* @saleor/Shopex-JS
|
||||
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
ARG NODE_VERSION=22.9.0
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
ENV NODE_VERSION="$NODE_VERSION"
|
||||
|
||||
|
||||
LABEL fly_launch_runtime="Next.js"
|
||||
|
||||
# Next.js app lives here
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
ARG PNPM_VERSION=8.12.0
|
||||
RUN npm install -g pnpm@$PNPM_VERSION
|
||||
|
||||
|
||||
FROM base AS build
|
||||
|
||||
# Set these via `docker run -e APP_DEBUG=debug ...`
|
||||
# OR set them in your deployed containers environment secrets
|
||||
# these are needed for pnpm install schema:generate
|
||||
ENV APL='file'
|
||||
ENV APP_DEBUG='debug'
|
||||
# do not replace these here, set these in the deployed environment, or docker run -e ...
|
||||
# ENV TEST_SALEOR_API_URL=''
|
||||
# ENV UPSTASH_TOKEN=''
|
||||
# ENV UPSTASH_URL=''
|
||||
# ENV SECRET_KEY='test-see-comment-above'
|
||||
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
COPY . .
|
||||
RUN SECRET_KEY='test-see-comment-above' pnpm run build
|
||||
# Remove development dependencies
|
||||
RUN pnpm prune --prod
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
||||
# Copy built application
|
||||
COPY --from=build /app /app
|
||||
|
||||
# Start the server by default, this can be overwritten at runtime
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "run", "start" ]
|
||||
37
LICENSE
Normal file
@@ -0,0 +1,37 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020-2022, Saleor Commerce
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
-------
|
||||
|
||||
Unless stated otherwise, artwork included in this distribution is licensed
|
||||
under the Creative Commons Attribution 4.0 International License.
|
||||
|
||||
You can learn more about the permitted use by visiting
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
305
README.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Stripe App
|
||||
|
||||
> [!CAUTION]
|
||||
> Stripe App is not a production-ready app, but an integration example. It is not feature complete and must be self-hosted.
|
||||
|
||||
> [!TIP]
|
||||
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
|
||||
|
||||
## Overview
|
||||
|
||||
Stripe App is a payment integration app that allows merchants to accept online payments from customers using Stripe as their payment processor. Stripe is a popular global payment provider that offers a range of payment methods, including credit cards, bank transfers, and digital wallets.
|
||||
|
||||
You can find an example of using the Stripe App at [https://github.com/saleor/example-nextjs-stripe/](https://github.com/saleor/example-nextjs-stripe/).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To configure the Stripe App, you must have an account with [Stripe](https://stripe.com).
|
||||
|
||||
The Stripe App allows for integrations with [Stripe Payment Element](https://stripe.com/docs/payments/payment-element), meaning it can be used on [Web, iOS, Android, and React Native](https://stripe.com/docs/payments/accept-a-payment?platform=web). Under the hood, it creates Stripe [Payment Intents](https://stripe.com/docs/api/payment_intents) and handles calculations of total and balance in Saleor automatically.
|
||||
|
||||
> [!NOTE]
|
||||
> Stripe App uses Stripe API version [2022-11-15](https://stripe.com/docs/api/versioning).
|
||||
|
||||
## Capabilities
|
||||
|
||||
The Stripe App implements the following [Saleor synchronous events related to transactions](https://docs.saleor.io/docs/3.x/developer/extending/webhooks/synchronous-events/transaction):
|
||||
|
||||
- [`PAYMENT_GATEWAY_INITIALIZE_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumpayment_gateway_initialize_session)
|
||||
- [`TRANSACTION_INITIALIZE_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_initialize_session)
|
||||
- [`TRANSACTION_PROCESS_SESSION`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_process_session)
|
||||
- [`TRANSACTION_CHARGE_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_charge_requested)
|
||||
- [`TRANSACTION_CANCEL_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_cancel_requested)
|
||||
- [`TRANSACTION_REFUND_REQUESTED`](https://docs.saleor.io/docs/3.x/api-reference/webhooks/enums/webhook-event-type-sync-enum#webhookeventtypesyncenumtransaction_refund_requested)
|
||||
|
||||
Furthermore, it's also prepared to handle [Stripe incoming webhooks](https://stripe.com/docs/webhooks).
|
||||
|
||||
Stripe App follows the flow described in detail in the [Saleor Payment App documentation](https://docs.saleor.io/docs/3.x/developer/payments#payment-app).
|
||||
|
||||
## Configuration
|
||||
|
||||
For Stripe to appear as [available payment gateway](https://docs.saleor.io/docs/3.x/developer/checkout/finalizing#listing-available-payment-gateways), you need to [install it in the Saleor Dashboard](https://docs.saleor.io/docs/3.x/developer/app-store/overview#usage). You must obtain the Secret Key and Publishable Key from Stripe and paste it into the Stripe App configuration form. Webhooks to receive notifications from Stripe will be configured automatically.
|
||||
|
||||
> [!CAUTION]
|
||||
> Stripe App doesn't work with Restricted Keys.
|
||||
|
||||
## Usage in Storefront or mobile apps
|
||||
|
||||
Stripe App can be used to integrate with Stripe APIs. By using a set of GraphQL mutations, one can interact with Stripe to authorize, capture, refund, and cancel payments.
|
||||
|
||||
### Getting payment gateways
|
||||
|
||||
The first step is to fetch the Checkout object including [`availablePaymentGateways`](https://docs.saleor.io/docs/3.x/api-reference/checkout/objects/checkout#checkoutavailablepaymentgatewayspaymentgateway--) field. The `availablePaymentGateways` field contains a list of payment gateways available for given checkout. The Stripe App should be one of the payment gateways available in the list. Its `id` is [`app.saleor.stripe`](https://stripe.saleor.app/api/manifest) - defined in app's manifest.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
checkout(id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy") {
|
||||
availablePaymentGateways {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"checkout": {
|
||||
"availablePaymentGateways": [
|
||||
{
|
||||
"id": "app.saleor.stripe",
|
||||
"name": "Stripe"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `availablePaymentGateways` may contain other Payment Apps as well as [legacy plugins](https://docs.saleor.io/docs/3.x/developer/extending/payment-gateways) configured in the Dashboard. You should ignore the ones that you don't want to use for a specific checkout.
|
||||
|
||||
### Paying with Stripe Payment Element
|
||||
|
||||
To initialize the Stripe Payment Element, one needs to create a transaction in Saleor by calling the [`transactionInitialize`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/transaction-initialize) mutation.
|
||||
|
||||
```graphql
|
||||
mutation StripeTransactionInitialize($data: JSON!) {
|
||||
transactionInitialize(
|
||||
id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy"
|
||||
amount: 54.24
|
||||
paymentGateway: { id: "app.saleor.stripe", data: $data }
|
||||
) {
|
||||
transactionEvent {
|
||||
pspReference
|
||||
amount {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
type
|
||||
}
|
||||
data
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `$data` is an object passed to Stripe API to create a Payment Intent, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"automatic_payment_methods": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The mutation returns the `TransactionInitialize` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"transactionInitialize": {
|
||||
"transactionEvent": {
|
||||
"pspReference": "XXXX9XXXXXXXXX99",
|
||||
"amount": {
|
||||
"amount": 54.24,
|
||||
"currency": "EUR"
|
||||
},
|
||||
"type": "CHARGE_REQUESTED"
|
||||
},
|
||||
"data": {
|
||||
"paymentIntent": {
|
||||
"client_secret": "…"
|
||||
},
|
||||
"publishableKey": "…"
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`client_secret` and `publishableKey` can be used to initialize the Stripe Payment Element.
|
||||
|
||||
You can find a working example in this repository: [saleor/example-nextjs-stripe](https://github.com/saleor/example-nextjs-stripe/)
|
||||
|
||||
### Modifying the payment intent
|
||||
|
||||
You can use the [`transactionProcess`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/transaction-process) mutation to modify the payment intent. For example:
|
||||
|
||||
```graphql
|
||||
mutation StripeTransactionProcess($data: JSON!) {
|
||||
transactionProcess(
|
||||
id: "Q2hlY2tvdXQ6YWY3MDJkMGQtMzM0NC00NjMxLTlkNmEtMDk4Yzk1ODhlNmMy"
|
||||
data: $data
|
||||
) {
|
||||
transactionEvent {
|
||||
pspReference
|
||||
amount {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
type
|
||||
}
|
||||
data
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `$data` is an object passed to Stripe API to edit a Payment Intent, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"automatic_payment_methods": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving publishable key
|
||||
|
||||
In some cases, you might want to retrieve just the publishable key without creating any transactions in Saleor. This is particularly useful on a payment summary page where you want to display the details of Stripe Payment Intent. To do so, [`paymentGatewayInitialize`](https://docs.saleor.io/docs/3.x/api-reference/payments/mutations/payment-gateway-initialize) mutation can be used:
|
||||
|
||||
```graphql
|
||||
mutation PaymentGatewayInitialize($checkoutId: ID!) {
|
||||
paymentGatewayInitialize(
|
||||
id: $checkoutId
|
||||
amount: 0
|
||||
paymentGateways: [{ id: "app.saleor.stripe" }]
|
||||
) {
|
||||
gatewayConfigs {
|
||||
id
|
||||
data
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"paymentGatewayInitialize": {
|
||||
"gatewayConfigs": [
|
||||
{
|
||||
"id": "app.saleor.stripe",
|
||||
"data": {
|
||||
"publishableKey": "pk_test_…"
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can find an example of using the Stripe App at [https://github.com/saleor/example-nextjs-stripe/](https://github.com/saleor/example-nextjs-stripe/).
|
||||
|
||||
## Development
|
||||
|
||||
To run the Stripe App locally:
|
||||
|
||||
1. Go to the app directory.
|
||||
2. Copy the `.env.example` file to `.env`.The `.env` should contain the following variables:
|
||||
|
||||
> [!NOTE]
|
||||
> Stripe App is a Next.js application. If you want to learn more about setting environment variables in Next.js, head over to the [documentation](https://nextjs.org/docs/basic-features/environment-variables).
|
||||
|
||||
`SECRET_KEY` (_required_)
|
||||
|
||||
A randomly generated key that encrypts metadata stored in Saleor. At least eight characters long.
|
||||
|
||||
`APL` (_optional_)
|
||||
|
||||
Name of the chosen implementation of the [Authentication Persistence Layer](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
|
||||
|
||||
When no value is provided, `FileAPL` is used by default. See `saleor-app.ts` in the app directory to see supported APLs.
|
||||
|
||||
`APP_DEBUG` (_optional_)
|
||||
|
||||
The logging level for the app. The possible values are: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, and `silent`. The default value is `info` which means that some information will be logged into the console.
|
||||
|
||||
You can read more about our logger in [its documentation](https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter).
|
||||
|
||||
### Running app in development mode
|
||||
|
||||
To run the app in development mode, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> pnpm 8.0.0 or higher is required to run the app.
|
||||
|
||||
The app will be available at `http://localhost:3000`.
|
||||
|
||||
> [!NOTE]
|
||||
> To test Stripe Webhooks, you need to expose your local server to the internet (tunnel). You can use Saleor CLI or Stripe CLI to do that. See [this guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels) for more details.
|
||||
|
||||
### Running tests
|
||||
|
||||
To run tests, one needs to provide additional environment variables. Copy the `.env.test` file to `.env.test.local`.The `.env.test.local` should contain the following variables:
|
||||
|
||||
| env variable name | required? | description | example |
|
||||
| ---------------------------------- | :--------: | :------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TEST_SALEOR_API_URL` | _required_ | Full URL to the Saleor GraphQL endpoint | `https://saleor.cloud/graphql/` |
|
||||
| `TEST_SALEOR_APP_TOKEN` | _required_ | [AppToken](https://docs.saleor.io/docs/3.x/api-reference/apps/objects/app-token) | `3DZ7CbFTyPETthDixPtFpPysoKG4FP` |
|
||||
| `TEST_SALEOR_APP_ID` | _required_ | [App.id](https://docs.saleor.io/docs/3.x/api-reference/apps/objects/app) | `QXBwOjk=` |
|
||||
| `TEST_SALEOR_JWKS` | _required_ | stringified JWKS | `"{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"...\", \"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}"` |
|
||||
| `TEST_PAYMENT_APP_SECRET_KEY` | _required_ | Secret Key from Stripe | `sk_test_51LVZwxEosE…` |
|
||||
| `TEST_PAYMENT_APP_PUBLISHABLE_KEY` | _required_ | Publishable Key from Stripe | `pk_test_51LVZwxEos…` |
|
||||
| `TEST_PAYMENT_APP_WEBHOOK_ID` | _required_ | ID of a webhook | `we_1JaGFlH1Vac4G4dbZnQ8bviV` |
|
||||
| `TEST_PAYMENT_APP_WEBHOOK_SECRET` | _required_ | Webhook Secret from Stripe | `whsec_c09e3d87…` |
|
||||
|
||||
Then run the following command:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in saleor-app-payment-stripe please disclose it via [our huntr page](https://huntr.dev/repos/saleor/saleor-app-payment-stripe/). Information about bounties, CVEs, response times and past reports are all there.
|
||||
|
||||
Thank you for improving the security of saleor-app-payment-stripe.
|
||||
22
fix-coverage-report.cjs
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
// https://github.com/ArtiomTr/jest-coverage-report-action/issues/244#issuecomment-1260555231
|
||||
const fs = require("fs");
|
||||
|
||||
const testReportFilename = process.cwd() + "/coverage/report.json";
|
||||
const coverageReportFilename = process.cwd() + "/coverage/coverage-final.json";
|
||||
|
||||
const testReport = require(testReportFilename);
|
||||
const coverageReport = require(coverageReportFilename);
|
||||
|
||||
testReport.coverageMap = coverageReport;
|
||||
|
||||
fs.writeFile(testReportFilename, JSON.stringify(testReport), (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Coverage report appended to " + testReportFilename);
|
||||
});
|
||||
39
global.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
type NonFalsy<T> = T extends false | 0 | "" | null | undefined | 0n ? never : T;
|
||||
|
||||
interface Array<T> {
|
||||
includes(searchElement: unknown, fromIndex?: number): searchElement is T;
|
||||
}
|
||||
|
||||
interface ReadonlyArray<T> {
|
||||
includes(searchElement: unknown, fromIndex?: number): searchElement is T;
|
||||
}
|
||||
|
||||
interface Body {
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface Array<T> {
|
||||
filter(predicate: BooleanConstructor, thisArg?: unknown): NonFalsy<T>[];
|
||||
}
|
||||
|
||||
interface ReadonlyArray<T> {
|
||||
filter(predicate: BooleanConstructor, thisArg?: unknown): NonFalsy<T>[];
|
||||
}
|
||||
|
||||
interface ArrayConstructor {
|
||||
isArray(arg: unknown): arg is unknown[];
|
||||
}
|
||||
|
||||
interface JSON {
|
||||
/**
|
||||
* Converts a JavaScript Object Notation (JSON) string into an object.
|
||||
* @param text A valid JSON string.
|
||||
* @param reviver A function that transforms the results. This function is called for each member of the object.
|
||||
* If a member contains nested objects, the nested objects are transformed before the parent object is.
|
||||
*/
|
||||
parse(text: string, reviver?: (this: unknown, key: string, value: unknown) => unknown): unknown;
|
||||
}
|
||||
|
||||
interface Set<T> {
|
||||
has(value: unknown): value is T;
|
||||
}
|
||||
0
graphql/fragments/.gitkeep
Normal file
4
graphql/fragments/Money.graphql
Normal file
@@ -0,0 +1,4 @@
|
||||
fragment Money on Money {
|
||||
currency
|
||||
amount
|
||||
}
|
||||
110
graphql/fragments/OrderOrCheckoutLines.graphql
Normal file
@@ -0,0 +1,110 @@
|
||||
fragment OrderOrCheckoutLines on OrderOrCheckout {
|
||||
__typename
|
||||
... on Checkout {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
shippingPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
net {
|
||||
...Money
|
||||
}
|
||||
tax {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
deliveryMethod {
|
||||
__typename
|
||||
... on ShippingMethod {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
lines {
|
||||
__typename
|
||||
id
|
||||
quantity
|
||||
totalPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
net {
|
||||
...Money
|
||||
}
|
||||
tax {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
checkoutVariant: variant {
|
||||
name
|
||||
sku
|
||||
product {
|
||||
name
|
||||
thumbnail {
|
||||
url
|
||||
}
|
||||
category {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
... on Order {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
shippingPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
net {
|
||||
...Money
|
||||
}
|
||||
tax {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
deliveryMethod {
|
||||
__typename
|
||||
... on ShippingMethod {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
lines {
|
||||
__typename
|
||||
id
|
||||
quantity
|
||||
taxRate
|
||||
totalPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
net {
|
||||
...Money
|
||||
}
|
||||
tax {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
orderVariant: variant {
|
||||
name
|
||||
sku
|
||||
product {
|
||||
name
|
||||
thumbnail {
|
||||
url
|
||||
}
|
||||
category {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
graphql/fragments/OrderOrCheckoutSourceObject.graphql
Normal file
@@ -0,0 +1,45 @@
|
||||
fragment OrderOrCheckoutSourceObject on OrderOrCheckout {
|
||||
__typename
|
||||
... on Checkout {
|
||||
id
|
||||
languageCode
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
userEmail: email
|
||||
billingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
shippingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
total: totalPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
... on Order {
|
||||
id
|
||||
languageCodeEnum
|
||||
userEmail
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
billingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
shippingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
total {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
fragment PaymentGatewayInitializeSessionAddress on Address {
|
||||
country {
|
||||
code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
fragment PaymentGatewayInitializeSessionEvent on PaymentGatewayInitializeSession {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
data
|
||||
amount
|
||||
issuingPrincipal {
|
||||
... on Node {
|
||||
id
|
||||
}
|
||||
}
|
||||
sourceObject {
|
||||
__typename
|
||||
... on Checkout {
|
||||
id
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
languageCode
|
||||
billingAddress {
|
||||
...PaymentGatewayInitializeSessionAddress
|
||||
}
|
||||
total: totalPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
}
|
||||
... on Order {
|
||||
id
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
languageCodeEnum
|
||||
userEmail
|
||||
billingAddress {
|
||||
...PaymentGatewayInitializeSessionAddress
|
||||
}
|
||||
total {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
graphql/fragments/PaymentGatewayRecipient.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
fragment PaymentGatewayRecipient on App {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
fragment TransactionCancelationRequestedEvent on TransactionCancelationRequested {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
action {
|
||||
actionType
|
||||
amount
|
||||
}
|
||||
transaction {
|
||||
id
|
||||
pspReference
|
||||
sourceObject: order {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
graphql/fragments/TransactionChargeRequestedEvent.graphql
Normal file
@@ -0,0 +1,24 @@
|
||||
fragment TransactionChargeRequestedEvent on TransactionChargeRequested {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
action {
|
||||
amount
|
||||
actionType
|
||||
}
|
||||
transaction {
|
||||
id
|
||||
pspReference
|
||||
sourceObject: order {
|
||||
... on Order {
|
||||
total {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
fragment TransactionInitializeSessionAddress on Address {
|
||||
firstName
|
||||
lastName
|
||||
phone
|
||||
city
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
postalCode
|
||||
countryArea
|
||||
companyName
|
||||
country {
|
||||
code
|
||||
}
|
||||
}
|
||||
26
graphql/fragments/TransactionInitializeSessionEvent.graphql
Normal file
@@ -0,0 +1,26 @@
|
||||
fragment TransactionInitializeSessionEvent on TransactionInitializeSession {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
data
|
||||
merchantReference
|
||||
action {
|
||||
amount
|
||||
currency
|
||||
actionType
|
||||
}
|
||||
issuingPrincipal {
|
||||
... on Node {
|
||||
id
|
||||
}
|
||||
}
|
||||
transaction {
|
||||
id
|
||||
pspReference
|
||||
}
|
||||
sourceObject {
|
||||
__typename
|
||||
...OrderOrCheckoutSourceObject
|
||||
}
|
||||
}
|
||||
54
graphql/fragments/TransactionProcessSessionEvent.graphql
Normal file
@@ -0,0 +1,54 @@
|
||||
fragment TransactionProcessSessionEvent on TransactionProcessSession {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
data
|
||||
merchantReference
|
||||
action {
|
||||
amount
|
||||
currency
|
||||
actionType
|
||||
}
|
||||
transaction {
|
||||
id
|
||||
pspReference
|
||||
}
|
||||
sourceObject {
|
||||
__typename
|
||||
... on Checkout {
|
||||
id
|
||||
languageCode
|
||||
userEmail: email
|
||||
billingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
shippingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
total: totalPrice {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
... on Order {
|
||||
id
|
||||
languageCodeEnum
|
||||
userEmail
|
||||
billingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
shippingAddress {
|
||||
...TransactionInitializeSessionAddress
|
||||
}
|
||||
total {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
}
|
||||
}
|
||||
24
graphql/fragments/TransactionRefundRequestedEvent.graphql
Normal file
@@ -0,0 +1,24 @@
|
||||
fragment TransactionRefundRequestedEvent on TransactionRefundRequested {
|
||||
__typename
|
||||
recipient {
|
||||
...PaymentGatewayRecipient
|
||||
}
|
||||
action {
|
||||
amount
|
||||
actionType
|
||||
}
|
||||
transaction {
|
||||
id
|
||||
pspReference
|
||||
sourceObject: order {
|
||||
... on Order {
|
||||
total {
|
||||
gross {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
}
|
||||
...OrderOrCheckoutLines
|
||||
}
|
||||
}
|
||||
}
|
||||
0
graphql/mutations/.gitkeep
Normal file
28
graphql/mutations/TransactionEventReport.graphql
Normal file
@@ -0,0 +1,28 @@
|
||||
mutation TransactionEventReport(
|
||||
$transactionId: ID!
|
||||
$amount: PositiveDecimal!
|
||||
$availableActions: [TransactionActionEnum!]!
|
||||
$externalUrl: String!
|
||||
$message: String
|
||||
$pspReference: String!
|
||||
$time: DateTime!
|
||||
$type: TransactionEventTypeEnum!
|
||||
) {
|
||||
transactionEventReport(
|
||||
id: $transactionId
|
||||
amount: $amount
|
||||
availableActions: $availableActions
|
||||
externalUrl: $externalUrl
|
||||
message: $message
|
||||
pspReference: $pspReference
|
||||
time: $time
|
||||
type: $type
|
||||
) {
|
||||
alreadyProcessed
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
10
graphql/mutations/UpdateAppMetadata.graphql
Normal file
@@ -0,0 +1,10 @@
|
||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
graphql/mutations/UpdatePublicMetadata.graphql
Normal file
@@ -0,0 +1,10 @@
|
||||
mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
graphql/queries/.gitkeep
Normal file
16
graphql/queries/FetchAppDetails.graphql
Normal file
@@ -0,0 +1,16 @@
|
||||
query FetchAppDetails {
|
||||
shop {
|
||||
schemaVersion
|
||||
}
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
6
graphql/queries/FetchChannels.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
query FetchChannels {
|
||||
channels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
28869
graphql/schema.graphql
Normal file
0
graphql/subscriptions/.gitkeep
Normal file
@@ -0,0 +1,5 @@
|
||||
subscription PaymentGatewayInitializeSession {
|
||||
event {
|
||||
...PaymentGatewayInitializeSessionEvent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription TransactionCancelationRequested {
|
||||
event {
|
||||
...TransactionCancelationRequestedEvent
|
||||
}
|
||||
}
|
||||
5
graphql/subscriptions/TransactionChargeRequested.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
subscription TransactionChargeRequested {
|
||||
event {
|
||||
...TransactionChargeRequestedEvent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription TransactionInitializeSession {
|
||||
event {
|
||||
...TransactionInitializeSessionEvent
|
||||
}
|
||||
}
|
||||
5
graphql/subscriptions/TransactionProcessSession.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
subscription TransactionProcessSession {
|
||||
event {
|
||||
...TransactionProcessSessionEvent
|
||||
}
|
||||
}
|
||||
5
graphql/subscriptions/TransactionRefundRequested.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
subscription TransactionRefundRequested {
|
||||
event {
|
||||
...TransactionRefundRequestedEvent
|
||||
}
|
||||
}
|
||||
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
31
next.config.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
|
||||
import { createVanillaExtractPlugin } from "@vanilla-extract/next-plugin";
|
||||
const withVanillaExtract = createVanillaExtractPlugin();
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
|
||||
* This is especially useful for Docker builds.
|
||||
*/
|
||||
!process.env.SKIP_ENV_VALIDATION && (await import("./src/lib/env.mjs"));
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
/** @param { import("webpack").Configuration } config */
|
||||
webpack(config) {
|
||||
config.experiments = { ...config.experiments, topLevelAwait: true };
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
const isSentryEnabled = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
const vanillaExtractConfig = withVanillaExtract(config);
|
||||
|
||||
export default isSentryEnabled
|
||||
? withSentryConfig(vanillaExtractConfig, { silent: true }, { hideSourceMaps: true })
|
||||
: vanillaExtractConfig;
|
||||
152
package.json
Normal file
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "saleor-app-payment-stripe",
|
||||
"description": "Saleor App Payment Stripe is a payment integration app that allows merchants using the Saleor e-commerce platform to accept online payments from customers using Stripe as their payment processor.",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"repository": "github:saleor/saleor-app-payment-stripe",
|
||||
"homepage": "https://github.com/saleor/saleor-app-payment-stripe",
|
||||
"bugs": {
|
||||
"url": "https://github.com/saleor/saleor-app-payment-stripe/issues",
|
||||
"email": "hello@saleor.io"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm generate && next dev",
|
||||
"build": "pnpm generate && next build",
|
||||
"deploy": "tsx ./src/deploy.ts",
|
||||
"start": "next start",
|
||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||
"generate": "pnpm generate:graphql && pnpm generate:schema",
|
||||
"generate:graphql": "graphql-codegen",
|
||||
"generate:schema": "pnpm saleor-schema-compiler compile ./src/schemas --definitions ./src/schemas/definitions.json",
|
||||
"lint": "next lint --fix --dir src",
|
||||
"lint:ci": "next lint --dir src",
|
||||
"test": "vitest",
|
||||
"test:record": "POLLY_MODE=record_missing vitest",
|
||||
"test:rerecord": "POLLY_MODE=record vitest",
|
||||
"test:ci": "CI=true vitest --coverage --reporter=json --reporter=default && tsx fix-coverage-report.cjs",
|
||||
"migrate": "pnpm tsx ./src/run-migrations.ts",
|
||||
"ts-node-esm": "node --loader ts-node/esm --experimental-specifier-resolution=node",
|
||||
"prepare": "node -e \"try { require('husky').install() } catch (e) {if (e.code !== 'MODULE_NOT_FOUND') throw e}\"",
|
||||
"github:release": "pnpm changeset tag && git push --follow-tags"
|
||||
},
|
||||
"saleor": {
|
||||
"schemaVersion": "3.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "3.3.2",
|
||||
"@next/env": "14.0.4",
|
||||
"@radix-ui/react-alert-dialog": "1.0.5",
|
||||
"@saleor/app-sdk": "0.47.2",
|
||||
"@saleor/macaw-ui": "0.8.0-pre.123",
|
||||
"@sentry/nextjs": "7.86.0",
|
||||
"@t3-oss/env-nextjs": "0.7.1",
|
||||
"@tanstack/react-query": "4",
|
||||
"@tanstack/react-query-devtools": "4.36.1",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/next": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"@urql/exchange-auth": "1.0.0",
|
||||
"@vanilla-extract/css": "1.14.0",
|
||||
"@vanilla-extract/next-plugin": "2.3.2",
|
||||
"@vanilla-extract/recipes": "0.5.1",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"ajv": "8.12.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"bluebird": "3.7.2",
|
||||
"classnames": "2.3.2",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-tag": "2.12.6",
|
||||
"jose": "5.1.3",
|
||||
"jsdom": "23.0.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"modern-errors": "7.0.0",
|
||||
"modern-errors-http": "5.0.0",
|
||||
"modern-errors-serialize": "6.0.0",
|
||||
"next": "14.2.10",
|
||||
"omit-deep-lodash": "1.1.7",
|
||||
"pino": "8.16.2",
|
||||
"pino-pretty": "10.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.49.1",
|
||||
"semver": "7.5.4",
|
||||
"stripe": "14.8.0",
|
||||
"stripe-event-types": "3.1.0",
|
||||
"tsx": "4.6.2",
|
||||
"url-join": "5.0.0",
|
||||
"urql": "3.0.4",
|
||||
"uuidv7": "0.6.3",
|
||||
"webpack": "5.94.0",
|
||||
"yup": "1.3.2",
|
||||
"zod": "3.22.4",
|
||||
"zustand": "4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "2.27.1",
|
||||
"@graphql-codegen/add": "5.0.0",
|
||||
"@graphql-codegen/cli": "5.0.0",
|
||||
"@graphql-codegen/introspection": "4.0.0",
|
||||
"@graphql-codegen/typed-document-node": "5.0.1",
|
||||
"@graphql-codegen/typescript": "4.0.1",
|
||||
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||
"@graphql-codegen/typescript-urql": "4.0.0",
|
||||
"@graphql-codegen/urql-introspection": "3.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@pollyjs/adapter-fetch": "6.0.6",
|
||||
"@pollyjs/adapter-node-http": "6.0.6",
|
||||
"@pollyjs/core": "6.0.6",
|
||||
"@pollyjs/persister-fs": "6.0.6",
|
||||
"@saleor/eslint-plugin-saleor-app": "0.1.2",
|
||||
"@saleor/json-schema-compiler": "0.1.2",
|
||||
"@testing-library/jest-dom": "6.1.5",
|
||||
"@testing-library/react": "14.1.2",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/bluebird": "3.5.42",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/node": "20.10.4",
|
||||
"@types/omit-deep-lodash": "1.1.3",
|
||||
"@types/react": "18.2.45",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@types/semver": "7.5.6",
|
||||
"@types/setup-polly-jest": "0.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.14.0",
|
||||
"@typescript-eslint/parser": "6.14.0",
|
||||
"@vanilla-extract/vite-plugin": "3.9.3",
|
||||
"@vitest/coverage-v8": "0.34.2",
|
||||
"vite": "5.0.8",
|
||||
"dependency-cruiser": "15.5.0",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-require-form-method": "1.0.2",
|
||||
"eslint-plugin-testing-library": "6.2.0",
|
||||
"eslint-plugin-vitest": "0.3.16",
|
||||
"husky": "8.0.3",
|
||||
"json-schema-to-typescript": "13.1.1",
|
||||
"lint-staged": "15.2.0",
|
||||
"next-test-api-route-handler": "3.1.10",
|
||||
"prettier": "3.1.1",
|
||||
"setup-polly-jest": "0.11.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.3.3",
|
||||
"vite-tsconfig-paths": "4.2.2",
|
||||
"vitest": "0.34.2"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.0.0 <10.0.0",
|
||||
"node": "^20.0.0 || ^22.0.0",
|
||||
"pnpm": "~8.12.0"
|
||||
},
|
||||
"packageManager": "pnpm@8.12.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@urql/exchange-auth>@urql/core": "3.2.2",
|
||||
"@typescript-eslint/parser": "$@typescript-eslint/parser",
|
||||
"vite": "$vite"
|
||||
}
|
||||
}
|
||||
}
|
||||
12008
pnpm-lock.yaml
generated
Normal file
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 453 B |
BIN
public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 733 B |
BIN
public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 978 B |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
18
sentry.client.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file configures the initialization of Sentry on the browser.
|
||||
// The config you add here will be used whenever a page is visited.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { env } from "./src/lib/env.mjs";
|
||||
|
||||
const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
16
sentry.edge.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever middleware or an Edge route handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { env } from "./src/lib/env.mjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
4
sentry.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
defaults.url=https://sentry.io/
|
||||
defaults.org=saleor
|
||||
defaults.project=
|
||||
cli.executable=
|
||||
18
sentry.server.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { env } from "./src/lib/env.mjs";
|
||||
|
||||
const SENTRY_DSN = env.SENTRY_DSN || env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
20
src/__tests__/apiTestsUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type HttpMethod } from "../lib/api-response";
|
||||
import { type JSONValue } from "../types";
|
||||
|
||||
export const host = "https://localhost:4213";
|
||||
|
||||
export const createRequestMock = (
|
||||
method: HttpMethod,
|
||||
body?: JSONValue,
|
||||
headers = new Headers(),
|
||||
) => {
|
||||
if (body) {
|
||||
headers.append("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(`${host}/api/route`, {
|
||||
method,
|
||||
headers,
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
};
|
||||
26
src/__tests__/pages/index.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, vi, describe, it } from "vitest";
|
||||
import IndexPage from "../../pages";
|
||||
|
||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
||||
return {
|
||||
useAppBridge: () => ({
|
||||
appBridgeState: {},
|
||||
appBridge: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/router", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders text", () => {
|
||||
render(<IndexPage />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Install this app in your Saleor Dashboard", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
269
src/__tests__/polly.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import path from "path";
|
||||
import NodeHttpAdapter from "@pollyjs/adapter-node-http";
|
||||
import FetchAdapter from "@pollyjs/adapter-fetch";
|
||||
import { Polly, type Headers, type PollyConfig } from "@pollyjs/core";
|
||||
import FSPersister from "@pollyjs/persister-fs";
|
||||
import { afterEach, beforeEach, expect } from "vitest";
|
||||
import merge from "lodash-es/merge";
|
||||
import omit from "lodash-es/omit";
|
||||
import omitDeep from "omit-deep-lodash";
|
||||
import { tryJsonParse, tryIgnore } from "../lib/utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface TestContext {
|
||||
polly?: Polly;
|
||||
}
|
||||
}
|
||||
|
||||
export const omitPathsFromJson = (paths: string[]) => (input: string) => {
|
||||
return JSON.stringify(omit(JSON.parse(input) as object, paths));
|
||||
};
|
||||
export const omitPathsFromHeaders = (paths: string[]) => (headers: Headers) => {
|
||||
return omit(headers, paths);
|
||||
};
|
||||
|
||||
const HEADERS_BLACKLIST = new Set([
|
||||
"authorization-bearer",
|
||||
"authorization",
|
||||
"saleor-signature",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-stripe-client-user-agent",
|
||||
]);
|
||||
|
||||
const VARIABLES_BLACKLIST = new Set([
|
||||
"csrfToken",
|
||||
"email",
|
||||
"newEmail",
|
||||
"newPassword",
|
||||
"oldPassword",
|
||||
"password",
|
||||
"redirectUrl",
|
||||
"refreshToken",
|
||||
"token",
|
||||
"authorisationToken",
|
||||
]);
|
||||
|
||||
const removeBlacklistedVariables = (
|
||||
obj: {} | undefined | string | null,
|
||||
): {} | undefined | string | null => {
|
||||
if (!obj || typeof obj === "string") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if ("client_secret" in obj) {
|
||||
obj.client_secret = "pi_FAKE_CLIENT_SECRET";
|
||||
}
|
||||
|
||||
return omitDeep(obj, ...VARIABLES_BLACKLIST);
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface is incomplete
|
||||
*/
|
||||
interface PollyRecording {
|
||||
response: {
|
||||
content?: {
|
||||
mimeType: string;
|
||||
text: string;
|
||||
};
|
||||
cookies: string[];
|
||||
headers: Array<{ value: string; name: string }>;
|
||||
};
|
||||
request: {
|
||||
postData?: {
|
||||
text: string;
|
||||
};
|
||||
cookies: string[];
|
||||
headers: Array<{ value: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const responseIsJson = (recording: PollyRecording) => {
|
||||
return recording.response.content?.mimeType.includes("application/json");
|
||||
};
|
||||
const requestIsJson = (recording: PollyRecording) => {
|
||||
return recording.request.headers.some(
|
||||
({ name, value }) =>
|
||||
name.toLowerCase() === "content-type" && value.includes("application/json"),
|
||||
);
|
||||
};
|
||||
|
||||
export const setupRecording = (config?: PollyConfig) => {
|
||||
Polly.on("create", (polly) => {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
polly.server
|
||||
.any()
|
||||
// Hide sensitive data in headers or in body
|
||||
.on("beforePersist", (_req, recording: PollyRecording) => {
|
||||
recording.response.cookies = [];
|
||||
|
||||
recording.response.headers = recording.response.headers.filter(
|
||||
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
|
||||
);
|
||||
recording.request.headers = recording.request.headers.filter(
|
||||
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
|
||||
);
|
||||
|
||||
if (recording.request.postData?.text) {
|
||||
const requestJson = tryJsonParse(recording.request.postData.text);
|
||||
const filteredRequestJson = removeBlacklistedVariables(requestJson);
|
||||
recording.request.postData.text =
|
||||
typeof filteredRequestJson === "string"
|
||||
? filteredRequestJson
|
||||
: JSON.stringify(filteredRequestJson);
|
||||
}
|
||||
if (recording.response.content?.text) {
|
||||
const responseJson = tryJsonParse(recording.response.content.text);
|
||||
const filteredResponseJson = removeBlacklistedVariables(responseJson);
|
||||
recording.response.content.text =
|
||||
typeof filteredResponseJson === "string"
|
||||
? filteredResponseJson
|
||||
: JSON.stringify(filteredResponseJson);
|
||||
}
|
||||
})
|
||||
// make JSON response and requests more readable
|
||||
// https://github.com/Netflix/pollyjs/issues/322
|
||||
.on("beforePersist", (_req, recording: PollyRecording) => {
|
||||
if (responseIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.response.content!.text = JSON.parse(
|
||||
recording.response.content!.text,
|
||||
) as string),
|
||||
);
|
||||
}
|
||||
|
||||
if (requestIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.request.postData!.text = JSON.parse(
|
||||
recording.request.postData!.text,
|
||||
) as string),
|
||||
);
|
||||
}
|
||||
})
|
||||
.on("beforeReplay", (_req, recording: PollyRecording) => {
|
||||
if (responseIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.response.content!.text = JSON.stringify(recording.response.content!.text)),
|
||||
);
|
||||
}
|
||||
|
||||
if (requestIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.request.postData!.text = JSON.stringify(recording.request.postData!.text)),
|
||||
);
|
||||
}
|
||||
});
|
||||
/* eslint-enable */
|
||||
});
|
||||
|
||||
const defaultConfig = {
|
||||
...getRecordingSettings(),
|
||||
adapters: [FetchAdapter, NodeHttpAdapter],
|
||||
persister: FSPersister,
|
||||
adapterOptions: {
|
||||
fetch: {
|
||||
context: globalThis,
|
||||
},
|
||||
},
|
||||
persisterOptions: {
|
||||
fs: {},
|
||||
keepUnusedRequests: false,
|
||||
},
|
||||
flushRequestsOnStop: true,
|
||||
matchRequestsBy: {
|
||||
url: {
|
||||
protocol: true,
|
||||
username: true,
|
||||
password: true,
|
||||
hostname: true,
|
||||
port: true,
|
||||
pathname: true,
|
||||
query: true,
|
||||
hash: false,
|
||||
},
|
||||
body: true,
|
||||
order: false,
|
||||
method: true,
|
||||
headers: {
|
||||
exclude: [
|
||||
"date",
|
||||
"idempotency-key",
|
||||
"original-request",
|
||||
"request-id",
|
||||
"content-length",
|
||||
"x-stripe-client-user-agent",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async (ctx) => {
|
||||
const { currentTestName } = expect.getState();
|
||||
if (!currentTestName) {
|
||||
throw new Error("This function must be run inside a test case!");
|
||||
}
|
||||
|
||||
const recordingsRoot = path.dirname(expect.getState().testPath || "");
|
||||
const recordingsDirectory = path.join(recordingsRoot, "__recordings__");
|
||||
|
||||
const [, ...names] = currentTestName.split(" > ");
|
||||
const polly = new Polly(
|
||||
names.join("/"),
|
||||
merge(
|
||||
defaultConfig,
|
||||
{
|
||||
persisterOptions: {
|
||||
fs: {
|
||||
recordingsDir: recordingsDirectory,
|
||||
},
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
);
|
||||
ctx.polly = polly;
|
||||
});
|
||||
|
||||
afterEach((ctx) => ctx.polly?.flush());
|
||||
afterEach((ctx) => ctx.polly?.stop());
|
||||
};
|
||||
|
||||
const getRecordingSettings = (): Pick<
|
||||
PollyConfig,
|
||||
"mode" | "recordIfMissing" | "recordFailedRequests"
|
||||
> => {
|
||||
// use replay mode by default, override if POLLY_MODE env variable is passed
|
||||
const mode = env.CI ? "replay" : testEnv.POLLY_MODE;
|
||||
|
||||
if (mode === "record") {
|
||||
return {
|
||||
mode: "record",
|
||||
recordIfMissing: true,
|
||||
recordFailedRequests: true,
|
||||
};
|
||||
}
|
||||
if (mode === "record_missing") {
|
||||
return {
|
||||
mode: "replay",
|
||||
recordIfMissing: true,
|
||||
recordFailedRequests: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "replay",
|
||||
recordIfMissing: false,
|
||||
recordFailedRequests: false,
|
||||
};
|
||||
};
|
||||
51
src/__tests__/test-env.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const testEnvSchema = z.object({
|
||||
// Saleor
|
||||
TEST_SALEOR_API_URL: z.string().url(),
|
||||
TEST_SALEOR_APP_TOKEN: z.string(),
|
||||
TEST_SALEOR_APP_ID: z.string(),
|
||||
TEST_SALEOR_JWKS: z.string(),
|
||||
// Payment App
|
||||
TEST_PAYMENT_APP_SECRET_KEY: z.string(),
|
||||
TEST_PAYMENT_APP_PUBLISHABLE_KEY: z.string(),
|
||||
TEST_PAYMENT_APP_WEBHOOK_SECRET: z.string(),
|
||||
TEST_PAYMENT_APP_WEBHOOK_ID: z.string(),
|
||||
// Polly.js
|
||||
POLLY_MODE: z.enum(["record", "record_missing", "replay"]).optional().default("replay"),
|
||||
});
|
||||
|
||||
const processEnv = {
|
||||
// Saleor
|
||||
TEST_SALEOR_API_URL: process.env.TEST_SALEOR_API_URL,
|
||||
TEST_SALEOR_APP_TOKEN: process.env.TEST_SALEOR_APP_TOKEN,
|
||||
TEST_SALEOR_APP_ID: process.env.TEST_SALEOR_APP_ID,
|
||||
TEST_SALEOR_JWKS: process.env.TEST_SALEOR_JWKS,
|
||||
// Payment App
|
||||
TEST_PAYMENT_APP_SECRET_KEY: process.env.TEST_PAYMENT_APP_SECRET_KEY,
|
||||
TEST_PAYMENT_APP_PUBLISHABLE_KEY: process.env.TEST_PAYMENT_APP_PUBLISHABLE_KEY,
|
||||
TEST_PAYMENT_APP_WEBHOOK_SECRET: process.env.TEST_PAYMENT_APP_WEBHOOK_SECRET,
|
||||
TEST_PAYMENT_APP_WEBHOOK_ID: process.env.TEST_PAYMENT_APP_WEBHOOK_ID,
|
||||
// Polly.js
|
||||
POLLY_MODE: process.env.POLLY_MODE,
|
||||
};
|
||||
|
||||
/* c8 ignore start */
|
||||
/** @type z.infer<testEnvSchema>
|
||||
* @ts-ignore - can't type this properly in jsdoc */
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
let testEnv = process.env;
|
||||
|
||||
if (!!process.env.SKIP_ENV_VALIDATION == false) {
|
||||
const parsed = testEnvSchema.safeParse(processEnv);
|
||||
|
||||
if (parsed.success === false) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
testEnv = parsed.data;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
export { testEnv };
|
||||
59
src/__tests__/testAPL.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type AplConfiguredResult,
|
||||
type AplReadyResult,
|
||||
type APL,
|
||||
type AuthData,
|
||||
} from "@saleor/app-sdk/APL";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
export class TestAPL implements APL {
|
||||
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
|
||||
if (testEnv.TEST_SALEOR_API_URL !== saleorApiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
|
||||
jwks: "",
|
||||
appId: testEnv.TEST_SALEOR_APP_ID,
|
||||
token: testEnv.TEST_SALEOR_APP_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
async set(authData: AuthData) {
|
||||
console.warn("Attempted to save APL authData in test", authData);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
console.warn("Attempted to delete APL authData in test");
|
||||
}
|
||||
|
||||
async getDomain(): Promise<string | undefined> {
|
||||
return new URL(testEnv.TEST_SALEOR_API_URL).hostname;
|
||||
}
|
||||
|
||||
async isReady(): Promise<AplReadyResult> {
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return [
|
||||
{
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
|
||||
jwks: "",
|
||||
appId: testEnv.TEST_SALEOR_APP_ID,
|
||||
token: testEnv.TEST_SALEOR_APP_TOKEN,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<AplConfiguredResult> {
|
||||
return {
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/app-bridge-instance.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
294
src/backend-lib/api-route-utils.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { type ValidateFunction } from "ajv";
|
||||
import { type NextApiResponse, type NextApiRequest } from "next/types";
|
||||
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { getAuthDataForRequest, getSyncWebhookHandler, validateData } from "./api-route-utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
import { BaseError, MissingSaleorApiUrlError } from "@/errors";
|
||||
|
||||
describe("api-route-utils", () => {
|
||||
describe("validateData", () => {
|
||||
it("should return data if it validates", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => true) as unknown as ValidateFunction),
|
||||
).resolves.toEqual(data);
|
||||
});
|
||||
|
||||
it("should throw error if it doesn't validate", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => false) as unknown as ValidateFunction),
|
||||
).rejects.toMatchInlineSnapshot("[UnknownError: JsonSchemaError]");
|
||||
});
|
||||
|
||||
it("should throw error if it throws", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => {
|
||||
throw new Error("some error");
|
||||
}) as unknown as ValidateFunction),
|
||||
).rejects.toMatchInlineSnapshot("[UnknownError: some error]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSyncWebhookHandler", () => {
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
it("should return a function", () => {
|
||||
expect(
|
||||
getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
(() => {}) as any,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
),
|
||||
).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("calls handler with payload and saleorApiUrl from context", async () => {
|
||||
const handler = vi.fn();
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json() {} } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(payload, authData.saleorApiUrl);
|
||||
});
|
||||
|
||||
it("returns json with result", async () => {
|
||||
const handler = vi.fn().mockReturnValue({
|
||||
some: "json",
|
||||
});
|
||||
const json = vi.fn();
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
some: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("catches known errors and returns 200 with details", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new BaseError("This is a known error", {
|
||||
props: {
|
||||
errorCode: 123,
|
||||
statusCode: 422,
|
||||
},
|
||||
errors: [new Error("Initial problem")],
|
||||
});
|
||||
});
|
||||
const errorMapper = vi.fn();
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
errorMapper,
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(errorMapper.mock.lastCall[1]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "BaseError",
|
||||
"details": {},
|
||||
"message": "This is a known error",
|
||||
},
|
||||
{
|
||||
"code": "Error",
|
||||
"message": "Initial problem",
|
||||
},
|
||||
],
|
||||
"message": "This is a known error",
|
||||
"sentry": [
|
||||
[BaseError: This is a known error],
|
||||
{
|
||||
"extra": {
|
||||
"errors": [
|
||||
[Error: Initial problem],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("catches known errors and responds with whatever the errorMapper returns", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new MissingSaleorApiUrlError("Missing");
|
||||
});
|
||||
const errorMapper = vi.fn().mockImplementation((payload, error) => {
|
||||
return {
|
||||
errors: error.errors,
|
||||
message: error.message,
|
||||
};
|
||||
});
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
errorMapper,
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(json.mock.lastCall[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "MissingSaleorApiUrlError",
|
||||
"details": {},
|
||||
"message": "Missing",
|
||||
},
|
||||
],
|
||||
"message": "Missing",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("catches unknown errors and returns 500", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Some error");
|
||||
});
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(BaseError.normalize(json.mock.lastCall[0])).toMatchInlineSnapshot(
|
||||
"[BaseError: Some error]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthDataForRequest", () => {
|
||||
it("should throw if there's no saleroApiUrl in the query", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({ query: {} } as NextApiRequest),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
"[MissingSaleorApiUrlError: Missing saleorApiUrl query param]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if data doesn't exist in APL", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({ query: { saleorApiUrl: "someurl" } } as unknown as NextApiRequest),
|
||||
).rejects.toMatchInlineSnapshot("[MissingAuthDataError: APL for someurl not found]");
|
||||
});
|
||||
|
||||
it("should return data from apl if it exists", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({
|
||||
query: { saleorApiUrl: testEnv.TEST_SALEOR_API_URL },
|
||||
} as unknown as NextApiRequest),
|
||||
).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"appId": "123456",
|
||||
"domain": "saleor.localhost",
|
||||
"jwks": "",
|
||||
"saleorApiUrl": "https://saleor.localhost:8080/graphql/",
|
||||
"token": "123456",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/backend-lib/api-route-utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { createLogger, redactError } from "../lib/logger";
|
||||
import {
|
||||
JsonSchemaError,
|
||||
UnknownError,
|
||||
BaseError,
|
||||
MissingAuthDataError,
|
||||
MissingSaleorApiUrlError,
|
||||
} from "@/errors";
|
||||
import { toStringOrEmpty } from "@/lib/utils";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
|
||||
export const validateData = async <S extends ValidateFunction>(data: unknown, validate: S) => {
|
||||
type Result = S extends ValidateFunction<infer T> ? T : never;
|
||||
try {
|
||||
const isValid = validate(data);
|
||||
if (!isValid) {
|
||||
throw JsonSchemaError.normalize(validate.errors);
|
||||
}
|
||||
return data as Result;
|
||||
} catch (err) {
|
||||
throw UnknownError.normalize(err);
|
||||
}
|
||||
};
|
||||
|
||||
export function getSyncWebhookHandler<TPayload, TResult, TSchema extends ValidateFunction<TResult>>(
|
||||
name: string,
|
||||
webhookHandler: (payload: TPayload, saleorApiUrl: string) => Promise<TResult>,
|
||||
ResponseSchema: TSchema,
|
||||
errorMapper: (payload: TPayload, errorResponse: ErrorResponse) => TResult & {},
|
||||
): NextWebhookApiHandler<TPayload> {
|
||||
return async (_req, res: NextApiResponse<Error | TResult>, ctx) => {
|
||||
const logger = createLogger(
|
||||
{
|
||||
event: ctx.event,
|
||||
},
|
||||
{ msgPrefix: `[${name}] ` },
|
||||
);
|
||||
const { authData, payload } = ctx;
|
||||
logger.info(`handler called: ${webhookHandler.name}`);
|
||||
logger.debug({ payload }, "ctx payload");
|
||||
|
||||
try {
|
||||
const result = await webhookHandler(payload, authData.saleorApiUrl);
|
||||
logger.info(`${webhookHandler.name} was successful`);
|
||||
logger.debug({ result }, "Sending successful response");
|
||||
return res.json(await validateData(result, ResponseSchema));
|
||||
} catch (err) {
|
||||
logger.error({ err: redactError(err) }, `${webhookHandler.name} error`);
|
||||
|
||||
const response = errorToResponse(err);
|
||||
|
||||
if (!response) {
|
||||
Sentry.captureException(err);
|
||||
const result = BaseError.serialize(err);
|
||||
logger.debug("Sending error response");
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
|
||||
Sentry.captureException(...response.sentry);
|
||||
const finalErrorResponse = errorMapper(payload, response);
|
||||
logger.debug({ finalErrorResponse }, "Sending error response");
|
||||
return res.status(200).json(await validateData(finalErrorResponse, ResponseSchema));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type ErrorResponse = Exclude<ReturnType<typeof errorToResponse>, null>;
|
||||
const errorToResponse = (err: unknown) => {
|
||||
const normalizedError = err instanceof BaseError ? err : null;
|
||||
|
||||
if (!normalizedError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sentry = [
|
||||
normalizedError,
|
||||
{
|
||||
extra: {
|
||||
errors: normalizedError.errors,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
const message = normalizedError.message;
|
||||
|
||||
const errors = [
|
||||
{
|
||||
code: normalizedError.name,
|
||||
message: normalizedError.message,
|
||||
details: {},
|
||||
},
|
||||
...(normalizedError.errors?.map((inner) => {
|
||||
return {
|
||||
code: inner.name,
|
||||
message: inner.message,
|
||||
};
|
||||
}) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
sentry,
|
||||
errors,
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAuthDataForRequest = async (request: NextApiRequest) => {
|
||||
const logger = createLogger({}, { msgPrefix: "[getAuthDataForRequest] " });
|
||||
|
||||
const saleorApiUrl = toStringOrEmpty(request.query.saleorApiUrl);
|
||||
logger.info(`Got saleorApiUrl=${saleorApiUrl || "<undefined>"}`);
|
||||
if (!saleorApiUrl) {
|
||||
throw new MissingSaleorApiUrlError("Missing saleorApiUrl query param");
|
||||
}
|
||||
|
||||
const authData = await saleorApp.apl.get(saleorApiUrl);
|
||||
logger.debug({ authData });
|
||||
if (!authData) {
|
||||
throw new MissingAuthDataError(`APL for ${saleorApiUrl} not found`);
|
||||
}
|
||||
|
||||
return authData;
|
||||
};
|
||||
12
src/deploy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable node/no-process-env */
|
||||
import { execSync } from "child_process";
|
||||
|
||||
execSync("pnpm run build", { stdio: "inherit" });
|
||||
|
||||
if (
|
||||
process.env.DEPLOYMENT_ENVIRONMENT === "production" ||
|
||||
process.env.DEPLOYMENT_ENVIRONMENT === "staging"
|
||||
) {
|
||||
console.log("Production environment detected, running migrations");
|
||||
execSync("pnpm run migrate", { stdio: "inherit" });
|
||||
}
|
||||
79
src/errors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type IncomingHttpHeaders } from "node:http2";
|
||||
import { type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
|
||||
import ModernError from "modern-errors";
|
||||
import ModernErrorsSerialize from "modern-errors-serialize";
|
||||
|
||||
// Http errors
|
||||
type CommonProps = {
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
name?: number;
|
||||
};
|
||||
|
||||
export const BaseError = ModernError.subclass("BaseError", {
|
||||
plugins: [ModernErrorsSerialize],
|
||||
props: {} as CommonProps,
|
||||
});
|
||||
export const UnknownError = BaseError.subclass("UnknownError");
|
||||
export const JsonSchemaError = BaseError.subclass("JsonSchemaError");
|
||||
export const MissingSaleorApiUrlError = BaseError.subclass("MissingSaleorApiUrlError");
|
||||
export const MissingAuthDataError = BaseError.subclass("MissingAuthDataError");
|
||||
export const HttpRequestError = BaseError.subclass("HttpRequestError", {
|
||||
props: {} as { statusCode: number; body: string; headers: IncomingHttpHeaders },
|
||||
});
|
||||
|
||||
// TRPC Errors
|
||||
export interface TrpcErrorOptions {
|
||||
/** HTTP response code returned by TRPC */
|
||||
trpcCode?: TRPC_ERROR_CODE_KEY;
|
||||
}
|
||||
export const BaseTrpcError = BaseError.subclass("BaseTrpcError", {
|
||||
props: { trpcCode: "INTERNAL_SERVER_ERROR" } as TrpcErrorOptions,
|
||||
});
|
||||
export const JwtTokenExpiredError = BaseTrpcError.subclass("JwtTokenExpiredError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const JwtInvalidError = BaseTrpcError.subclass("JwtInvalidError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingSaleorApiUrlError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingAuthDataError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingTokenError = BaseTrpcError.subclass("ReqMissingTokenError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingAppIdError = BaseTrpcError.subclass("ReqMissingAppIdError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
|
||||
// TRPC + react-hook-form errors
|
||||
export interface FieldErrorOptions extends TrpcErrorOptions {
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export const FieldError = BaseTrpcError.subclass("FieldError", {
|
||||
props: {} as FieldErrorOptions,
|
||||
});
|
||||
export const RestrictedKeyNotSupportedError = FieldError.subclass(
|
||||
"RestrictedKeyNotSupportedError",
|
||||
{
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
},
|
||||
);
|
||||
export const InvalidSecretKeyError = FieldError.subclass("InvalidSecretKeyError", {
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const UnexpectedSecretKeyError = FieldError.subclass("UnexpectedSecretKeyError", {
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const InvalidPublishableKeyError = FieldError.subclass("InvalidPublishableKeyError", {
|
||||
props: { fieldName: "publishableKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const UnexpectedPublishableKeyError = FieldError.subclass("UnexpectedPublishableKeyError", {
|
||||
props: { fieldName: "publishableKey" } as FieldErrorOptions,
|
||||
});
|
||||
|
||||
export const FileReaderError = BaseError.subclass("FileReaderError");
|
||||
71
src/lib/api-response.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type JSONValue } from "../types";
|
||||
|
||||
export const getResponse =
|
||||
(status: number) =>
|
||||
(data: JSONValue, headers: Headers = new Headers()) => {
|
||||
if (data) {
|
||||
headers.append("Content-Type", "application/json");
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
export const HttpStatus = {
|
||||
OK: 200,
|
||||
Created: 201,
|
||||
Accepted: 202,
|
||||
NonAuthoritativeInformation: 203,
|
||||
NoContent: 204,
|
||||
ResetContent: 205,
|
||||
PartialContent: 206,
|
||||
BadRequest: 400,
|
||||
Unauthorized: 401,
|
||||
PaymentRequired: 402,
|
||||
Forbidden: 403,
|
||||
NotFound: 404,
|
||||
MethodNotAllowed: 405,
|
||||
NotAcceptable: 406,
|
||||
ProxyAuthenticationRequired: 407,
|
||||
RequestTimeout: 408,
|
||||
Conflict: 409,
|
||||
Gone: 410,
|
||||
LengthRequired: 411,
|
||||
PreconditionFailed: 412,
|
||||
PayloadTooLarge: 413,
|
||||
URITooLong: 414,
|
||||
UnsupportedMediaType: 415,
|
||||
RangeNotSatisfiable: 416,
|
||||
ExpectationFailed: 417,
|
||||
ImaTeapot: 418,
|
||||
MisdirectedRequest: 421,
|
||||
UnprocessableEntity: 422,
|
||||
Locked: 423,
|
||||
FailedDependency: 424,
|
||||
TooEarly: 425,
|
||||
UpgradeRequired: 426,
|
||||
PreconditionRequired: 428,
|
||||
TooManyRequests: 429,
|
||||
RequestHeaderFieldsTooLarge: 431,
|
||||
UnavailableForLegalReasons: 451,
|
||||
InternalServerError: 500,
|
||||
} as const;
|
||||
export type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "HEAD";
|
||||
|
||||
export const ok = getResponse(HttpStatus.OK);
|
||||
export const created = getResponse(HttpStatus.Created);
|
||||
export const noContent = getResponse(HttpStatus.NoContent);
|
||||
|
||||
export const badRequest = getResponse(HttpStatus.BadRequest);
|
||||
export const unauthorized = getResponse(HttpStatus.Unauthorized);
|
||||
export const forbidden = getResponse(HttpStatus.Forbidden);
|
||||
export const notFound = getResponse(HttpStatus.NotFound);
|
||||
export const methodNotAllowed = (methods: string[]) =>
|
||||
getResponse(HttpStatus.MethodNotAllowed)(
|
||||
"Method not allowed",
|
||||
new Headers({ Allow: methods.join(", ") }),
|
||||
);
|
||||
export const conflict = getResponse(HttpStatus.Conflict);
|
||||
52
src/lib/create-graphq-client.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||
import {
|
||||
cacheExchange,
|
||||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
if (!authState || !authState?.token) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === "function"
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
|
||||
return {
|
||||
...operation,
|
||||
context: {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
"Authorization-Bearer": authState.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
||||
|
||||
export function createServerClient(saleorApiUrl: string, token: string) {
|
||||
return createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
}
|
||||
62
src/lib/env.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
// @ts-check
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
isServer: typeof window === "undefined" || process.env.NODE_ENV === "test",
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
ENV: z.enum(["development", "test", "staging", "production"]).default("development"),
|
||||
SECRET_KEY: z.string().min(8, { message: "Cannot be too short" }),
|
||||
SENTRY_DSN: z.string().min(1).optional(),
|
||||
APL: z.enum(["saleor-cloud", "upstash", "file"]).optional().default("file"),
|
||||
CI: z.coerce.boolean().optional().default(false),
|
||||
APP_DEBUG: z
|
||||
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
|
||||
.optional()
|
||||
.default("error"),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
PORT: z.coerce.number().optional(),
|
||||
UPSTASH_URL: z.string().optional(),
|
||||
UPSTASH_TOKEN: z.string().optional(),
|
||||
REST_APL_ENDPOINT: z.string().optional(),
|
||||
REST_APL_TOKEN: z.string().optional(),
|
||||
ALLOWED_DOMAIN_PATTERN: z.string().optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
* Environment variables available on the client (and server).
|
||||
*
|
||||
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.optional(z.string().min(1)),
|
||||
},
|
||||
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
* we need to manually destructure them to make sure all are included in bundle.
|
||||
*
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
ENV: process.env.ENV,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
SECRET_KEY: process.env.SECRET_KEY,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
APL: process.env.APL,
|
||||
CI: process.env.CI,
|
||||
APP_DEBUG: process.env.APP_DEBUG,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
PORT: process.env.PORT,
|
||||
UPSTASH_URL: process.env.UPSTASH_URL,
|
||||
UPSTASH_TOKEN: process.env.UPSTASH_TOKEN,
|
||||
REST_APL_ENDPOINT: process.env.REST_APL_ENDPOINT,
|
||||
REST_APL_TOKEN: process.env.REST_APL_TOKEN,
|
||||
ALLOWED_DOMAIN_PATTERN: process.env.ALLOWED_DOMAIN_PATTERN,
|
||||
},
|
||||
});
|
||||
8
src/lib/gql-ast-to-string.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copied from @saleor/app-sdk/gql-ast-to-string.ts
|
||||
import { type ASTNode, print } from "graphql";
|
||||
|
||||
export const gqlAstToString = (ast: ASTNode) =>
|
||||
print(ast) // convert AST to string
|
||||
.replaceAll(/\n+/g, " ") // remove new lines
|
||||
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
|
||||
.trim(); // remove whitespace from beginning and end
|
||||
13
src/lib/invariant.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { invariant } from "./invariant";
|
||||
|
||||
describe("invariant", () => {
|
||||
it.each([0, "", null, undefined, 0n])("should throw for %p", (value) => {
|
||||
expect(() => invariant(value)).toThrowError("Invariant failed: ");
|
||||
expect(() => invariant(value, "some message")).toThrowError("Invariant failed: some message");
|
||||
});
|
||||
|
||||
it.each([true, 1, "some str", {}, [], 123n])("should not throw for %p", (value) => {
|
||||
expect(() => invariant(value)).not.toThrowError();
|
||||
});
|
||||
});
|
||||
20
src/lib/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import ModernError from "modern-errors";
|
||||
|
||||
export const InvariantError = ModernError.subclass("InvariantError");
|
||||
|
||||
export function invariant(condition: unknown, message?: string): asserts condition {
|
||||
if (!condition) {
|
||||
const err = new InvariantError(`Invariant failed: ${message || ""}`);
|
||||
// remove utils.js from stack trace for better error messages
|
||||
const stack = (err.stack ?? "").split("\n");
|
||||
stack.splice(1, 1);
|
||||
err.stack = stack.join("\n");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
export function assertUnreachableButNotThrow(_: never) {
|
||||
return null as never;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
4
src/lib/isEnv.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable node/no-process-env */
|
||||
export const isTest = () => process.env.NODE_ENV === "test";
|
||||
export const isDevelopment = () => process.env.NODE_ENV === "development";
|
||||
export const isProduction = () => process.env.NODE_ENV === "production";
|
||||
103
src/lib/logger.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// We have to use process.env, otherwise pino doesn't work
|
||||
/* eslint-disable node/no-process-env */
|
||||
import pino from "pino";
|
||||
// import pinoPretty from "pino-pretty";
|
||||
// import { isDevelopment, isTest } from "./isEnv";
|
||||
import { isObject } from "./utils";
|
||||
import { obfuscateValue } from "@/modules/app-configuration/utils";
|
||||
import { BaseError, BaseTrpcError } from "@/errors";
|
||||
|
||||
/* c8 ignore start */
|
||||
export const logger = pino({
|
||||
level: process.env.APP_DEBUG ?? "info",
|
||||
redact: {
|
||||
paths: ["secretKey", "*[*].secretKey"],
|
||||
censor: (value) => redactLogValue(value),
|
||||
},
|
||||
// transport:
|
||||
// process.env.CI || isDevelopment() || isTest()
|
||||
// ? {
|
||||
// target: "pino-pretty",
|
||||
// options: {
|
||||
// colorize: true,
|
||||
// },
|
||||
// }
|
||||
// : undefined,
|
||||
});
|
||||
/* c8 ignore stop */
|
||||
|
||||
export const createLogger = logger.child.bind(logger);
|
||||
|
||||
export const redactLogValue = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
// non-string values are fully redacted to prevent leaks
|
||||
return "[REDACTED]";
|
||||
}
|
||||
|
||||
return obfuscateValue(value);
|
||||
};
|
||||
|
||||
export const redactError = (error: unknown) => {
|
||||
if (error instanceof BaseTrpcError) {
|
||||
const { message, name, errorCode, statusCode, trpcCode } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
errorCode,
|
||||
statusCode,
|
||||
trpcCode,
|
||||
};
|
||||
}
|
||||
if (error instanceof BaseError) {
|
||||
const { message, name, errorCode, statusCode } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
errorCode,
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const { message, name } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const redactLogObject = <T extends {}>(obj: T, callCount = 1): T => {
|
||||
if (callCount > 10) {
|
||||
logger.warn("Exceeded max call count for redactLogObject");
|
||||
return { _message: "[REDACTED - MAX CALL COUNT EXCEEDED]" } as unknown as T;
|
||||
}
|
||||
|
||||
const entries = Object.entries(obj).map(([key, value]) => {
|
||||
if (isObject(value)) {
|
||||
return [key, redactLogObject(value, callCount + 1)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return [key, redactLogArray(value)];
|
||||
}
|
||||
return [key, redactLogValue(value)];
|
||||
});
|
||||
return Object.fromEntries(entries) as T;
|
||||
};
|
||||
|
||||
export const redactLogArray = <T extends unknown[]>(array: T | undefined, callCount = 1): T => {
|
||||
if (!array) return [] as unknown as T;
|
||||
if (callCount > 10) {
|
||||
logger.warn("Exceeded max call count for redactLogArray");
|
||||
return [] as unknown as T;
|
||||
}
|
||||
|
||||
return array.map((item) => {
|
||||
if (isObject(item)) {
|
||||
return redactLogObject(item, callCount + 1);
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
return redactLogArray(item, callCount + 1);
|
||||
}
|
||||
return redactLogValue(item);
|
||||
}) as T;
|
||||
};
|
||||
119
src/lib/use-fetch.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import ModernError from "modern-errors";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { type z } from "zod";
|
||||
import { type JSONValue } from "../types";
|
||||
import { tryJsonParse } from "./utils";
|
||||
|
||||
export const FetchError = ModernError.subclass("FetchError", {
|
||||
props: {
|
||||
body: "",
|
||||
code: 500,
|
||||
},
|
||||
});
|
||||
export const FetchParseError = ModernError.subclass("FetchParseError");
|
||||
|
||||
type FetchConfig<T> = {
|
||||
schema: z.ZodType<T>;
|
||||
onSuccess?: (data: z.infer<z.ZodType<T>>) => void | Promise<void>;
|
||||
onError?: (
|
||||
err: InstanceType<typeof FetchError> | InstanceType<typeof FetchParseError>,
|
||||
) => void | Promise<void>;
|
||||
onFinished?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const useFetchFn = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { saleorApiUrl, token } = appBridgeState ?? {};
|
||||
|
||||
const customFetch = useCallback(
|
||||
(info: RequestInfo | URL, init?: RequestInit | undefined) => {
|
||||
return fetch(info, {
|
||||
...init,
|
||||
body: JSON.stringify(init?.body),
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"content-type": "application/json",
|
||||
"saleor-api-url": saleorApiUrl ?? "",
|
||||
"authorization-bearer": token ?? "",
|
||||
},
|
||||
});
|
||||
},
|
||||
[saleorApiUrl, token],
|
||||
);
|
||||
|
||||
return {
|
||||
fetch: customFetch,
|
||||
isReady: saleorApiUrl && token,
|
||||
};
|
||||
};
|
||||
|
||||
async function handleResponse<T>(res: Response, config: FetchConfig<T> | undefined): Promise<void> {
|
||||
if (!res.ok) {
|
||||
void config?.onError?.(
|
||||
new FetchError(res.statusText, {
|
||||
props: {
|
||||
body: await res.text(),
|
||||
code: res.status,
|
||||
},
|
||||
}),
|
||||
);
|
||||
void config?.onFinished?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const json = tryJsonParse(await res.text());
|
||||
const data = config.schema.parse(json);
|
||||
void config?.onSuccess?.(data);
|
||||
} catch (err) {
|
||||
void config?.onError?.(FetchParseError.normalize(err));
|
||||
}
|
||||
}
|
||||
void config?.onFinished?.();
|
||||
}
|
||||
|
||||
/** Fetch function, can be replaced to any fetching library, e.g. React Query, useSWR */
|
||||
export const useFetch = <T>(endpoint: string, config?: FetchConfig<T>) => {
|
||||
const { fetch, isReady } = useFetchFn();
|
||||
const configRef = useRef(config);
|
||||
|
||||
// We don't want changes in config to trigger re-fetch
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const res = await fetch(endpoint);
|
||||
await handleResponse(res, configRef.current);
|
||||
})();
|
||||
}, [endpoint, fetch, isReady]);
|
||||
};
|
||||
|
||||
export const usePost = <T>(endpoint: string, config?: FetchConfig<T>) => {
|
||||
const { fetch, isReady } = useFetchFn();
|
||||
|
||||
const submit = useCallback(
|
||||
async (data: JSONValue, options?: RequestInit | undefined) => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
await handleResponse(res, config);
|
||||
},
|
||||
[config, endpoint, fetch, isReady],
|
||||
);
|
||||
|
||||
return submit;
|
||||
};
|
||||
98
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tryIgnore, tryJsonParse, toStringOrEmpty, unpackPromise, unpackThrowable } from "./utils";
|
||||
import { BaseError } from "@/errors";
|
||||
|
||||
describe("api-route-utils", () => {
|
||||
describe("tryIgnore", () => {
|
||||
it("should run the function", () => {
|
||||
const fn = vi.fn();
|
||||
expect(() => tryIgnore(fn)).not.toThrow();
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should ignore errors", () => {
|
||||
expect(() =>
|
||||
tryIgnore(() => {
|
||||
throw new Error("Error!");
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryJsonParse", () => {
|
||||
it("should ignore empty input", () => {
|
||||
expect(tryJsonParse("")).toBeUndefined();
|
||||
expect(tryJsonParse(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should try parsing to JSON", () => {
|
||||
expect(tryJsonParse('{"a": 123, "b": {"c": "aaa"}}')).toEqual({ a: 123, b: { c: "aaa" } });
|
||||
});
|
||||
|
||||
it("should return original input in case of error", () => {
|
||||
expect(tryJsonParse('{"a": 123, "b" {"c": "aaa"}}')).toBe('{"a": 123, "b" {"c": "aaa"}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStringOrEmpty", () => {
|
||||
it("should return value if it's a string", () => {
|
||||
expect(toStringOrEmpty("")).toBe("");
|
||||
expect(toStringOrEmpty("some string")).toBe("some string");
|
||||
});
|
||||
|
||||
it.each([0, 1, 1n, {}, [], undefined, null, false, true])(
|
||||
"should return empty string if value is not a string: %p",
|
||||
(value) => {
|
||||
expect(toStringOrEmpty(value)).toBe("");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unpackPromise", () => {
|
||||
it("returns value if promise resolves", async () => {
|
||||
const [error, value] = await unpackPromise(Promise.resolve("some value"));
|
||||
expect(error).toBeNull();
|
||||
expect(value).toBe("some value");
|
||||
});
|
||||
|
||||
it("returns error if promise rejects", async () => {
|
||||
const [error, value] = await unpackPromise(Promise.reject("some error"));
|
||||
expect(error).toMatchInlineSnapshot("[UnknownError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves error if it's BaseError or descendants", async () => {
|
||||
const SomeError = BaseError.subclass("SomeError");
|
||||
const [error, value] = await unpackPromise(Promise.reject(new SomeError("some error")));
|
||||
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpackThrowable", () => {
|
||||
it("returns value if promise resolves", async () => {
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
return "some value";
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(value).toBe("some value");
|
||||
});
|
||||
|
||||
it("returns error if promise rejects", async () => {
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
throw new Error("some error");
|
||||
});
|
||||
expect(error).toMatchInlineSnapshot("[Error: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves error if it's BaseError or descendants", async () => {
|
||||
const SomeError = BaseError.subclass("SomeError");
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
throw new SomeError("some error");
|
||||
});
|
||||
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/lib/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { JSONValue } from "../types";
|
||||
import { BaseError, UnknownError } from "@/errors";
|
||||
|
||||
export const tryJsonParse = (text: string | undefined) => {
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as JSONValue;
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const tryIgnore = (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
export const toStringOrEmpty = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
return "";
|
||||
};
|
||||
|
||||
type PromiseToTupleResult<T> = [Error, null] | [null, Awaited<T>];
|
||||
export const unpackPromise = async <T extends Promise<unknown>>(
|
||||
promise: T,
|
||||
): Promise<PromiseToTupleResult<T>> => {
|
||||
try {
|
||||
const result = await promise;
|
||||
return [null, result];
|
||||
} catch (maybeError) {
|
||||
if (maybeError instanceof Error) {
|
||||
return [maybeError, null];
|
||||
}
|
||||
return [BaseError.normalize(maybeError, UnknownError), null];
|
||||
}
|
||||
};
|
||||
|
||||
type ThrowableToTupleResult<T> = [Error, null] | [null, T];
|
||||
export const unpackThrowable = <T>(throwable: () => T): ThrowableToTupleResult<T> => {
|
||||
try {
|
||||
const result = throwable();
|
||||
return [null, result];
|
||||
} catch (maybeError) {
|
||||
if (maybeError instanceof Error) {
|
||||
return [maybeError, null];
|
||||
}
|
||||
return [BaseError.normalize(maybeError, UnknownError), null];
|
||||
}
|
||||
};
|
||||
|
||||
export const isNotNullish = <T>(val: T | null | undefined): val is T =>
|
||||
val !== undefined && val !== null;
|
||||
|
||||
export const isObject = (val: unknown): val is Record<string, unknown> =>
|
||||
typeof val === "object" && val !== null && !Array.isArray(val);
|
||||
|
||||
export const __do = <T>(fn: () => T): T => fn();
|
||||
2
src/load-env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import nextEnv from "@next/env";
|
||||
nextEnv.loadEnvConfig(".");
|
||||
@@ -0,0 +1,10 @@
|
||||
query Migration_01_FetchWebhookIds {
|
||||
app {
|
||||
webhooks {
|
||||
id
|
||||
syncEvents {
|
||||
eventType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/migrations/1-add-issuing-pricinpal/UpdateWebhook.graphql
Normal file
@@ -0,0 +1,12 @@
|
||||
mutation Migration_01_UpdateWebhook($webhookId: ID!, $newQuery: String!) {
|
||||
webhookUpdate(id: $webhookId, input: { query: $newQuery }) {
|
||||
webhook {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/migrations/1-add-issuing-pricinpal/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type AuthData } from "@saleor/app-sdk/APL";
|
||||
import { createServerClient } from "@/lib/create-graphq-client";
|
||||
import {
|
||||
Migration_01_FetchWebhookIdsDocument,
|
||||
Migration_01_UpdateWebhookDocument,
|
||||
UntypedTransactionInitializeSessionDocument,
|
||||
WebhookEventTypeSyncEnum,
|
||||
} from "generated/graphql";
|
||||
import { gqlAstToString } from "@/lib/gql-ast-to-string";
|
||||
import { type PaymentAppConfigurator } from "@/modules/payment-app-configuration/payment-app-configuration";
|
||||
|
||||
export const requiredSaleorVersion = "3.13";
|
||||
|
||||
export async function migrate(authData: AuthData, _configurator: PaymentAppConfigurator) {
|
||||
const client = createServerClient(authData.saleorApiUrl, authData.token);
|
||||
const { data: fetchWebhookData } = await client
|
||||
.query(Migration_01_FetchWebhookIdsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
const webhook = fetchWebhookData?.app?.webhooks?.find((webhook) =>
|
||||
webhook.syncEvents.find(
|
||||
(syncEvent) => syncEvent.eventType === WebhookEventTypeSyncEnum.TransactionInitializeSession,
|
||||
),
|
||||
);
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error("No webhook to update");
|
||||
}
|
||||
|
||||
const webhookId = webhook.id;
|
||||
|
||||
const { data: updateWebhookData, error } = await client
|
||||
.mutation(Migration_01_UpdateWebhookDocument, {
|
||||
newQuery: gqlAstToString(UntypedTransactionInitializeSessionDocument),
|
||||
webhookId,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (error || !updateWebhookData?.webhookUpdate?.webhook?.id) {
|
||||
throw new Error("Error while updating webhook");
|
||||
}
|
||||
}
|
||||
159
src/modules/app-configuration/app-configuration.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
PrivateMetadataAppConfigurator,
|
||||
PublicMetadataAppConfiguration,
|
||||
serializeSettingsToMetadata,
|
||||
} from "./app-configuration";
|
||||
import {
|
||||
createWebhookPrivateSettingsManager,
|
||||
createWebhookPublicSettingsManager,
|
||||
} from "./metadata-manager";
|
||||
import { obfuscateValue, filterConfigValues, OBFUSCATION_DOTS } from "./utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
describe("obfuscateValue", () => {
|
||||
it("obfuscates fully short values", () => {
|
||||
expect(obfuscateValue("123")).toEqual(OBFUSCATION_DOTS);
|
||||
expect(obfuscateValue("")).toEqual(OBFUSCATION_DOTS);
|
||||
expect(obfuscateValue("1234")).toEqual(OBFUSCATION_DOTS);
|
||||
});
|
||||
|
||||
it("leaves 4 charts of obfuscated value visible", () => {
|
||||
expect(obfuscateValue("12345")).toBe(`${OBFUSCATION_DOTS}5`);
|
||||
expect(obfuscateValue("123456")).toBe(`${OBFUSCATION_DOTS}56`);
|
||||
expect(obfuscateValue("1234567")).toBe(`${OBFUSCATION_DOTS}567`);
|
||||
expect(obfuscateValue("12345678")).toBe(`${OBFUSCATION_DOTS}5678`);
|
||||
expect(obfuscateValue("123456789")).toBe(`${OBFUSCATION_DOTS}6789`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterConfigValues", () => {
|
||||
it("filters out null and undefined values", () => {
|
||||
expect(filterConfigValues({ a: 1, b: null, c: undefined })).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublicMetadataAppConfigurator", () => {
|
||||
const onUpdate = vi.fn((update: MetadataEntry[]) => Promise.resolve(update));
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate.mockClear();
|
||||
});
|
||||
|
||||
const KEY = "some-metadata";
|
||||
|
||||
const getMetadata = (value?: unknown) => {
|
||||
return serializeSettingsToMetadata({
|
||||
key: KEY,
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
value: value ? JSON.stringify(value) : "",
|
||||
});
|
||||
};
|
||||
const defaultMetadata = { a: "a" };
|
||||
|
||||
const managerEmpty = new PublicMetadataAppConfiguration(
|
||||
createWebhookPublicSettingsManager([], onUpdate),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
KEY,
|
||||
);
|
||||
|
||||
// make tests easier
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let manager: PublicMetadataAppConfiguration<any>;
|
||||
beforeEach(() => {
|
||||
manager = new PublicMetadataAppConfiguration(
|
||||
createWebhookPublicSettingsManager([getMetadata(defaultMetadata)], onUpdate),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
});
|
||||
|
||||
it("gets metadata from metadataManager and parses it", async () => {
|
||||
await expect(manager.getConfig()).resolves.toEqual(defaultMetadata);
|
||||
await expect(managerEmpty.getConfig()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets metadata in raw config form", async () => {
|
||||
await expect(manager.getRawConfig()).resolves.toEqual([getMetadata(defaultMetadata)]);
|
||||
await expect(managerEmpty.getRawConfig()).resolves.toEqual([getMetadata()]);
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("skips saving metadata if there is nothing new", async () => {
|
||||
await manager.setConfig({});
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces metadata if param is passed", async () => {
|
||||
await manager.setConfig({}, true);
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: JSON.stringify({}),
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]);
|
||||
|
||||
await manager.setConfig({ b: "b" }, true);
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
|
||||
});
|
||||
|
||||
it("saves only settings that have values", async () => {
|
||||
await managerEmpty.setConfig({ a: null, b: "b", c: undefined });
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
|
||||
});
|
||||
|
||||
it("merges new settings with existing ones", async () => {
|
||||
await manager.setConfig({ b: "b" });
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ a: "a", b: "b" })]);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears metadata", async () => {
|
||||
await manager.clearConfig();
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: "",
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivateMetadataAppConfigurator", () => {
|
||||
const metadataConfigurator = new PrivateMetadataAppConfigurator(
|
||||
createWebhookPublicSettingsManager([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: JSON.stringify({ a: "123456", b: "1234" }),
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
|
||||
it("can obfuscate provided config object", () => {
|
||||
expect(metadataConfigurator.obfuscateConfig({ a: "12345" })).toEqual({
|
||||
a: `${OBFUSCATION_DOTS}5`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty config when none is set", async () => {
|
||||
const emptyMetadataConfigurator = new PrivateMetadataAppConfigurator(
|
||||
createWebhookPrivateSettingsManager([]),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
await expect(emptyMetadataConfigurator.getConfigObfuscated()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("can obfuscate its own config", async () => {
|
||||
await expect(metadataConfigurator.getConfigObfuscated()).resolves.toEqual({
|
||||
a: `${OBFUSCATION_DOTS}56`,
|
||||
b: OBFUSCATION_DOTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
src/modules/app-configuration/app-configuration.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
type SettingsValue,
|
||||
type MetadataEntry,
|
||||
type SettingsManager,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import merge from "lodash-es/merge";
|
||||
import { toStringOrEmpty } from "../../lib/utils";
|
||||
import { filterConfigValues, obfuscateValue } from "./utils";
|
||||
import { logger as pinoLogger } from "@/lib/logger";
|
||||
|
||||
export interface GenericAppConfigurator<TConfig extends Record<string, unknown>> {
|
||||
setConfig(config: TConfig): Promise<void>;
|
||||
getConfig(): Promise<TConfig | undefined>;
|
||||
}
|
||||
|
||||
// Taken from @saleor/app-sdk/src/settings-manager
|
||||
export const serializeSettingsToMetadata = ({
|
||||
key,
|
||||
value,
|
||||
domain,
|
||||
}: SettingsValue): MetadataEntry => {
|
||||
// domain specific metadata use convention key__domain, e.g. `secret_key__example.com`
|
||||
if (!domain) {
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
return {
|
||||
key: [key, domain].join("__"),
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class MetadataConfigurator<TConfig extends Record<string, unknown>>
|
||||
implements GenericAppConfigurator<TConfig>
|
||||
{
|
||||
constructor(
|
||||
protected metadataManager: SettingsManager,
|
||||
protected saleorApiUrl: string,
|
||||
protected metadataKey: string,
|
||||
) {}
|
||||
|
||||
async getConfig(): Promise<TConfig | undefined> {
|
||||
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as TConfig;
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, cant be parsed");
|
||||
}
|
||||
}
|
||||
|
||||
async getRawConfig(
|
||||
prepareValue: (val: string) => string = (data) => data,
|
||||
): Promise<MetadataEntry[]> {
|
||||
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
|
||||
|
||||
return [
|
||||
// metadataManager strips out domain from key, we need to add it back
|
||||
serializeSettingsToMetadata({
|
||||
key: this.metadataKey,
|
||||
value: prepareValue(data ?? ""),
|
||||
domain: this.saleorApiUrl,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async setConfig(newConfig: Partial<TConfig>, replace = false) {
|
||||
const logger = pinoLogger.child({
|
||||
saleorApiUrl: this.saleorApiUrl,
|
||||
metadataKey: this.metadataKey,
|
||||
});
|
||||
const filteredNewConfig = filterConfigValues(newConfig);
|
||||
if (Object.keys(filteredNewConfig).length === 0 && !replace) {
|
||||
logger.debug("No config to safe in metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConfig = replace ? {} : await this.getConfig();
|
||||
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(merge(existingConfig, filteredNewConfig)),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async clearConfig() {
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: "",
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicMetadataAppConfiguration<
|
||||
TConfig extends Record<string, unknown>,
|
||||
> extends MetadataConfigurator<TConfig> {}
|
||||
|
||||
export class PrivateMetadataAppConfigurator<
|
||||
TConfig extends Record<string, unknown>,
|
||||
> extends MetadataConfigurator<TConfig> {
|
||||
constructor(metadataManager: SettingsManager, saleorApiUrl: string, metadataKey: string) {
|
||||
super(metadataManager, saleorApiUrl, metadataKey);
|
||||
}
|
||||
|
||||
obfuscateConfig(config: TConfig): TConfig {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
key,
|
||||
obfuscateValue(toStringOrEmpty(value)),
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as TConfig;
|
||||
}
|
||||
|
||||
async getConfigObfuscated(): Promise<TConfig | undefined> {
|
||||
const config = await this.getConfig();
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return this.obfuscateConfig(config);
|
||||
}
|
||||
}
|
||||
142
src/modules/app-configuration/metadata-manager.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
EncryptedMetadataManager,
|
||||
type MetadataEntry,
|
||||
MetadataManager,
|
||||
type MutateMetadataCallback,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import { type Client } from "urql";
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
type FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
UpdatePublicMetadataDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const combinedMetadata = [...(data?.app?.metadata || []), ...(data?.app?.privateMetadata || [])];
|
||||
|
||||
return combinedMetadata.map((md) => ({ key: md.key, value: md.value }));
|
||||
}
|
||||
|
||||
async function getAppId(client: Client) {
|
||||
const { error: idQueryError, data: idQueryData } = await client
|
||||
.query(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (idQueryError) {
|
||||
throw new Error(
|
||||
"Could not fetch the app id. Please check if auth data for the client are valid.",
|
||||
);
|
||||
}
|
||||
|
||||
const appId = idQueryData?.app?.id;
|
||||
|
||||
if (!appId) {
|
||||
throw new Error("Could not fetch the app ID");
|
||||
}
|
||||
|
||||
return appId;
|
||||
}
|
||||
|
||||
export async function mutatePrivateMetadata(client: Client, metadata: MetadataEntry[]) {
|
||||
// to update the metadata, ID is required
|
||||
const appId = await getAppId(client);
|
||||
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export async function mutatePublicMetadata(client: Client, metadata: MetadataEntry[]) {
|
||||
// to update the metadata, ID is required
|
||||
const appId = await getAppId(client);
|
||||
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdatePublicMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updateMetadata?.item?.metadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
// branded types are used to prevent using wrong manager for wrong metadata
|
||||
type Brand<K, T> = K & { __brand: T };
|
||||
export type BrandedEncryptedMetadataManager = Brand<
|
||||
EncryptedMetadataManager,
|
||||
"EncryptedMetadataManager"
|
||||
>;
|
||||
export type BrandedMetadataManager = Brand<MetadataManager, "MetadataManager">;
|
||||
|
||||
export const createPrivateSettingsManager = (client: Client) => {
|
||||
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
// We recommend it for production, because all values are encrypted.
|
||||
// If your use case require plain text values, you can use MetadataManager.
|
||||
return new EncryptedMetadataManager({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
encryptionKey: env.SECRET_KEY,
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutatePrivateMetadata(client, metadata),
|
||||
}) as BrandedEncryptedMetadataManager;
|
||||
};
|
||||
|
||||
export const createPublicSettingsManager = (client: Client) => {
|
||||
return new MetadataManager({
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutatePublicMetadata(client, metadata),
|
||||
}) as BrandedMetadataManager;
|
||||
};
|
||||
|
||||
export const createWebhookPrivateSettingsManager = (
|
||||
data: MetadataEntry[],
|
||||
onUpdate?: MutateMetadataCallback,
|
||||
) => {
|
||||
return new EncryptedMetadataManager({
|
||||
encryptionKey: env.SECRET_KEY,
|
||||
fetchMetadata: () => Promise.resolve(data),
|
||||
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
|
||||
}) as BrandedEncryptedMetadataManager;
|
||||
};
|
||||
|
||||
export const createWebhookPublicSettingsManager = (
|
||||
data: MetadataEntry[],
|
||||
onUpdate?: MutateMetadataCallback,
|
||||
) => {
|
||||
return new MetadataManager({
|
||||
fetchMetadata: () => Promise.resolve(data),
|
||||
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
|
||||
}) as BrandedMetadataManager;
|
||||
};
|
||||
38
src/modules/app-configuration/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { isNotNullish, toStringOrEmpty } from "../../lib/utils";
|
||||
|
||||
export const OBFUSCATION_DOTS = "••••";
|
||||
|
||||
export const obfuscateValue = (value: string) => {
|
||||
const unbofuscatedLength = Math.min(4, value.length - 4);
|
||||
|
||||
if (unbofuscatedLength <= 0) {
|
||||
return OBFUSCATION_DOTS;
|
||||
}
|
||||
|
||||
const visibleValue = value.slice(-unbofuscatedLength);
|
||||
return `${OBFUSCATION_DOTS}${visibleValue}`;
|
||||
};
|
||||
|
||||
export const deobfuscateValues = (values: Record<string, unknown>) => {
|
||||
const entries = Object.entries(values).map(
|
||||
([key, value]) =>
|
||||
[key, toStringOrEmpty(value).includes(OBFUSCATION_DOTS) ? null : value] as [string, unknown],
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
export const filterConfigValues = <T extends Record<string, unknown>>(values: T) => {
|
||||
const entries = Object.entries(values).filter(
|
||||
([_, value]) => value !== null && value !== undefined,
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
export const obfuscateConfig = <T extends {}>(config: T): T => {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
key,
|
||||
isNotNullish(value) ? obfuscateValue(toStringOrEmpty(value)) : value,
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as T;
|
||||
};
|
||||