Add Saleor Monitoring app (#189)

* initial commit

* Remove pre-commit-config

* Update gitignore

* Update README

* Add better config for monitoring app (#190)

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
This commit is contained in:
Przemysław Łada 2023-02-22 12:23:04 +01:00 committed by GitHub
parent 1c9b2c487a
commit b33bfd35af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 13859 additions and 0 deletions

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

125
apps/monitoring/.gitignore vendored Normal file
View file

@ -0,0 +1,125 @@
# Environments
.venv
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Ruff
.ruff_cache
# Backend
.fileApl.json

View file

@ -0,0 +1,20 @@
schema: backend/monitoring/schema.graphql
documents: [src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1,6 @@
.next
saleor/api.tsx
pnpm-lock.yaml
graphql/schema.graphql
generated
backend

View file

@ -0,0 +1,4 @@
{
"singleQuote": false,
"printWidth": 100
}

64
apps/monitoring/README.md Normal file
View file

@ -0,0 +1,64 @@
# Saleor Monitoring app
NOTE: This is Alpha version of the app.❗️
## Local development
### Start Monitoring backend
Run:
```shell
docker-compose up
```
It is beneficial to run this command in a separate terminal tab to observe backend logs easily.
By default, backend will run at `localhost:5001` with:
- Manifest at `/manifest`
- Graphql Playground at `/graphql`
- OpenApi viewer at `/docs`
### Develop frontend:
Installing dependencies with:
```shell
pnpm i
```
Running dev server
```shell
pnpm dev
```
The frontend app will run at `localhost:3000`.
By default, it acts as a proxy and redirects all unhandled requests to the backend (configured by `MONITORING_APP_API_URL` env).
This way, all frontend and backend endpoints are accessible at `http://localhost:3000`
### Test with Saleor
Expose `http://localhost:3000` using a tunnel and use `https://your.tunnel/manifest` manifest URL to install `Monitoring` app
### Graphql Playground
To use Graphql Playground, `Monitoring` app needs to be installed in Saleor, and HTTP headers must be set:
```json
{
"authorization-bearer": "token",
"saleor-api-url": "https://my-env.saleor.cloud/graphql/"
}
```
### Testing DataDog integration
Use these credentials sets to test DataDog integration:
Working credentials:
```json
{
"site": "US1",
"apiKey": "156e22d50c4e8b6816e1fd4794d3fd8c"
}
```
Credentials that validate but generate an error while sending events
```json
{
"site": "EU1",
"apiKey": "156e22d50c4e8b6816e1fd4794d3fd8c"
}
```

View file

@ -0,0 +1 @@
**/.fileApl.json

View file

@ -0,0 +1,28 @@
FROM python:3.10 as build
RUN pip install poetry'>=1.3.2,<1.4.0'
WORKDIR /app
COPY pyproject.toml poetry.lock /app/
RUN POETRY_VIRTUALENVS_CREATE=false poetry install --no-cache --only main
FROM python:3.10-slim as prod
ENV PYTHONUNBUFFERED 1
COPY --from=build /usr/local/bin/ /usr/local/bin/
COPY --from=build /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
WORKDIR /app
COPY monitoring/ /app/monitoring/
EXPOSE 80
CMD ["uvicorn", "monitoring.app:app", "--host", "0.0.0.0", "--port", "80", "--no-access-log", "--forwarded-allow-ips", "*"]
FROM prod as dev
COPY pyproject.toml poetry.lock /app/
RUN POETRY_VIRTUALENVS_CREATE=false poetry install --no-cache --only dev

View file

@ -0,0 +1 @@
__version__ = "0.1.0"

View file

@ -0,0 +1,15 @@
import uvicorn
def main():
uvicorn.run(
"monitoring.app:app",
host="0.0.0.0",
port=5001,
reload=True,
forwarded_allow_ips="*",
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,93 @@
from pathlib import Path
from ariadne import (
MutationType,
ObjectType,
QueryType,
convert_kwargs_to_snake_case,
load_schema_from_path,
make_executable_schema,
snake_case_fallback_resolvers,
)
from ariadne.asgi import GraphQL
from graphql import GraphQLResolveInfo
from .deps import ApiDependencies
from .schema import DatadogConfig, DatadogCredentials
from .settings import settings
base_dir = Path(__file__).resolve().parent
type_defs = load_schema_from_path(str(base_dir / "schema.graphql"))
query = QueryType()
datadog_config = ObjectType("DatadogConfig")
mutation = MutationType()
def get_api_context(info: GraphQLResolveInfo) -> ApiDependencies:
return info.context["request"].state.api_context
@datadog_config.field("credentials")
def resolve_datadog_credentials(datadog: DatadogConfig, *_):
return {
"site": datadog.credentials.site,
"api_key_last_4": datadog.credentials.api_key[-4:],
}
@query.field("integrations")
async def resolve_integrations(_, info):
context = get_api_context(info)
metadata = await context.manager.get_metadata()
return metadata
@mutation.field("updateDatadogConfig")
@convert_kwargs_to_snake_case
async def resolve_update_datadog(_, info, input):
context = get_api_context(info)
metadata = await context.manager.get_metadata()
if creds := input.get("credentials", None):
creds = DatadogCredentials(site=creds["site"], api_key=creds["api_key"])
if not await context.datadog_client.validate_credentials(creds):
return {
"errors": [
{
"field": "credentials",
"message": f"Invalid datadog apiKey for site {creds.site}",
}
]
}
# Create new configuration
if metadata.datadog is None:
if creds is None:
return {"errors": [{"message": "No DataDog config to update"}]}
metadata.datadog = DatadogConfig(credentials=creds)
if "active" in input:
metadata.datadog.active = input["active"]
if creds:
metadata.datadog.credentials = creds
metadata.datadog.error = None
await context.manager.save_private_metadata(metadata)
return {"datadog": metadata.datadog, "errors": []}
@mutation.field("deleteDatadogConfig")
async def resolve_delete_datadog(_, info):
context = get_api_context(info)
metadata = await context.manager.get_metadata()
if metadata.datadog is None:
return {"errors": [{"message": "No DataDog config to delete"}]}
await context.manager.delete_private_metadata("datadog")
return {"datadog": metadata.datadog, "errors": []}
schema = make_executable_schema(
type_defs, query, datadog_config, mutation, snake_case_fallback_resolvers
)
graphQL = GraphQL(schema, debug=settings.debug)
graphql_app = graphQL.http_handler

View file

@ -0,0 +1,12 @@
from .common import AplEntity, AplError, AplKeyError
from .wrapper import AplClient
apl_client = AplClient()
__all__ = [
"apl_client",
"AplClient",
"AplEntity",
"AplError",
"AplKeyError",
]

View file

@ -0,0 +1,6 @@
from .base import AplBackend
from .file import FileAplBackend
from .mem import MemAplBackend
from .rest import RestAplBackend
__all__ = ["AplBackend", "MemAplBackend", "FileAplBackend", "RestAplBackend"]

View file

@ -0,0 +1,25 @@
from typing import AsyncGenerator
from ..common import AplEntity
class AplBackend:
async def get(self, key: str) -> AplEntity:
raise NotImplementedError(
"subclasses of BaseAPLClient must provide a get() method"
)
async def set(self, key: str, value: AplEntity):
raise NotImplementedError(
"subclasses of BaseAPLClient must provide a add() method"
)
async def delete(self, key: str):
raise NotImplementedError(
"subclasses of BaseAPLClient must provide a delete() method"
)
def get_all(self, page_size: int) -> AsyncGenerator[tuple[str, AplEntity], None]:
raise NotImplementedError(
"subclasses of BaseAPLClient must provide a delete() method"
)

View file

@ -0,0 +1,31 @@
from json import dumps
from pathlib import Path
from fastapi.encoders import jsonable_encoder
from pydantic import parse_file_as
from ..common import AplEntity
from .mem import MemAplBackend
class FileAplBackend(MemAplBackend):
def _load_file(self):
self._apl = parse_file_as(dict[str, AplEntity], self.path)
def _save_file(self):
data = dumps(jsonable_encoder(self._apl), indent=2)
self.path.write_text(data)
def __init__(self, path: Path):
super().__init__()
self.path = path
if self.path.exists():
self._load_file()
async def set(self, *args, **kwargs):
await super().set(*args, **kwargs)
self._save_file()
async def delete(self, *args, **kwargs):
await super().delete(*args, **kwargs)
self._save_file()

View file

@ -0,0 +1,26 @@
from ..common import AplEntity, AplKeyError
from .base import AplBackend
class MemAplBackend(AplBackend):
def __init__(self):
self._apl: dict[str, AplEntity] = {}
async def get(self, key: str):
try:
return self._apl[key]
except KeyError as err:
raise AplKeyError(f"Key: {key} not found in MemApl") from err
async def set(self, key: str, value: AplEntity):
self._apl[key] = value
async def delete(self, key: str):
try:
del self._apl[key]
except KeyError as err:
raise AplKeyError(f"Key: {key} not found in MemApl") from err
async def get_all(self, page_size: int):
for key, val in self._apl.items():
yield key, val

View file

@ -0,0 +1,83 @@
import json
from base64 import urlsafe_b64encode
from contextlib import asynccontextmanager
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel
from ..common import AplEntity, AplError, AplKeyError
from .base import AplBackend
class AplPage(BaseModel):
count: int
next: str | None
previous: str | None
results: list[AplEntity]
class RestAplBackend(AplBackend):
def __init__(self, apl_url: str, token: str):
self.apl_url = apl_url
self.token = token
@property
def headers(self):
return {"Authorization": f"Bearer {self.token}"}
@staticmethod
def map_apl_entity(value: AplEntity):
return {
"saleor_app_id": value.app_id,
"saleor_api_url": value.saleor_api_url,
"jwks": json.dumps(value.jwks),
"domain": urlparse(value.saleor_api_url).netloc,
"token": value.app_token,
}
@staticmethod
def b64_encode(key: str):
return urlsafe_b64encode(key.encode()).decode()
@asynccontextmanager
async def _client(self):
headers = {"Authorization": f"Bearer {self.token}"}
async with httpx.AsyncClient(base_url=self.apl_url, headers=headers) as client:
try:
yield client
except httpx.HTTPError as exc:
raise AplError("RestApl error") from exc
async def set(self, key: str, value: AplEntity):
async with self._client() as client:
resp = await client.post("/api/v1/apl", json=self.map_apl_entity(value))
resp.raise_for_status()
async def get(self, key: str):
async with self._client() as client:
resp = await client.get(f"/api/v1/apl/{self.b64_encode(key)}")
if resp.status_code == 404:
raise AplKeyError(f"Key: {key} not found in RestApl")
resp.raise_for_status()
return AplEntity.parse_raw(resp.content)
async def delete(self, key: str):
async with self._client() as client:
resp = await client.delete(f"/api/v1/apl/{self.b64_encode(key)}")
if resp.status_code == 404:
raise AplKeyError(f"Key: {key} not found in RestApl")
resp.raise_for_status()
async def get_all(self, page_size: int):
async with self._client() as client:
offset, count = 0, 1
while offset < count:
params = {"limit": page_size, "offset": offset}
resp = await client.get("/api/v1/apl", params=params)
resp.raise_for_status()
page = AplPage.parse_raw(resp.content)
count = page.count
offset += page_size
for elem in page.results:
yield elem.saleor_api_url, elem

View file

@ -0,0 +1,32 @@
import json
from typing import Any
from pydantic import AnyHttpUrl, BaseModel, Field, validator
class AplEntity(BaseModel):
saleor_api_url: AnyHttpUrl
app_id: str = Field(..., alias="saleor_app_id")
app_token: str = Field(..., alias="token")
jwks: dict[str, Any]
class Config:
allow_population_by_field_name = True
@validator("jwks", pre=True)
def parse_json(cls, v):
if isinstance(v, str):
return json.loads(v)
return v
class AplError(Exception):
pass
class AplKeyError(AplError):
pass
class NotConfiguredError(AplError):
"""If apl client was not configured"""

View file

@ -0,0 +1,135 @@
interactions:
- request:
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:51:49 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: DELETE
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
response:
content: ''
headers:
Connection:
- keep-alive
Content-Length:
- '0'
Date:
- Sun, 19 Feb 2023 16:51:49 GMT
allow:
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 204
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
response:
content: '{"detail":"Not found."}'
headers:
Connection:
- keep-alive
Content-Length:
- '23'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:51:50 GMT
allow:
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 404
version: 1

View file

@ -0,0 +1,44 @@
interactions:
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: DELETE
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9ub24uZXhpc3RpbmcuZG9tYWlu
response:
content: '{"detail":"Not found."}'
headers:
Connection:
- keep-alive
Content-Length:
- '23'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:59:52 GMT
allow:
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 404
version: 1

View file

@ -0,0 +1,98 @@
interactions:
- request:
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:48:47 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9zYm5wc2Fnb3BpLnNhbGVvci5jbG91ZC9ncmFwaHFs
response:
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:48:48 GMT
allow:
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
version: 1

View file

@ -0,0 +1,742 @@
interactions:
- request:
body: '{"saleor_app_id": "uzfbqpkchx", "saleor_api_url": "https://sbnpsagopi.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "sbnpsagopi.saleor.cloud",
"token": "lbnetlmnjviofvjvleoyhotmbsaheg"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:37 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "fzglnnohvi", "saleor_api_url": "https://msltedyydo.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "msltedyydo.saleor.cloud",
"token": "etqfdiojfvufyfpylknharwbkkhcio"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://msltedyydo.saleor.cloud/graphql","token":"etqfdiojfvufyfpylknharwbkkhcio","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fzglnnohvi","domain":"msltedyydo.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:38 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "lcedtozpeo", "saleor_api_url": "https://rodnbaorcb.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "rodnbaorcb.saleor.cloud",
"token": "nyqokxizwonkuqqexhktbvgussupaz"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://rodnbaorcb.saleor.cloud/graphql","token":"nyqokxizwonkuqqexhktbvgussupaz","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"lcedtozpeo","domain":"rodnbaorcb.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:38 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "vxrgitlduz", "saleor_api_url": "https://vjoxywqxno.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vjoxywqxno.saleor.cloud",
"token": "uagntnmzugcynzcblilonyguxjowjq"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://vjoxywqxno.saleor.cloud/graphql","token":"uagntnmzugcynzcblilonyguxjowjq","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"vxrgitlduz","domain":"vjoxywqxno.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:39 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "mtljtfhfjd", "saleor_api_url": "https://txjaskukcx.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "txjaskukcx.saleor.cloud",
"token": "rzbhsxcngssozsamgksgqzsyykbvws"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://txjaskukcx.saleor.cloud/graphql","token":"rzbhsxcngssozsamgksgqzsyykbvws","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"mtljtfhfjd","domain":"txjaskukcx.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:39 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "krqomgdnue", "saleor_api_url": "https://wptoxotfln.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "wptoxotfln.saleor.cloud",
"token": "bfuugsnzlrmbtbfjhbsfduslfjbmty"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://wptoxotfln.saleor.cloud/graphql","token":"bfuugsnzlrmbtbfjhbsfduslfjbmty","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"krqomgdnue","domain":"wptoxotfln.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:39 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "qtpthrjavf", "saleor_api_url": "https://nxujxjrudl.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "nxujxjrudl.saleor.cloud",
"token": "vmwhdquypdsmsgjlcgtrqacmzbaunk"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://nxujxjrudl.saleor.cloud/graphql","token":"vmwhdquypdsmsgjlcgtrqacmzbaunk","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"qtpthrjavf","domain":"nxujxjrudl.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:40 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "nxvftnbrfb", "saleor_api_url": "https://vyyfdwmcav.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vyyfdwmcav.saleor.cloud",
"token": "wmsookfbmuopwwlvceiifcoaeicqje"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://vyyfdwmcav.saleor.cloud/graphql","token":"wmsookfbmuopwwlvceiifcoaeicqje","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"nxvftnbrfb","domain":"vyyfdwmcav.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:40 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "dpjoofueam", "saleor_api_url": "https://ejwnealqvw.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "ejwnealqvw.saleor.cloud",
"token": "teovwyfkqvpebpsmfpwigpqlusyrme"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://ejwnealqvw.saleor.cloud/graphql","token":"teovwyfkqvpebpsmfpwigpqlusyrme","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"dpjoofueam","domain":"ejwnealqvw.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:41 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: '{"saleor_app_id": "fxeyumrwrg", "saleor_api_url": "https://vxcdclzonj.saleor.cloud/graphql",
"jwks": "{\"keys\": [{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}", "domain": "vxcdclzonj.saleor.cloud",
"token": "vxhxemgqtpvvmzgcrxxetsrecvmocg"}'
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
content-length:
- '337'
content-type:
- application/json
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: POST
uri: https://apl.example.com/api/v1/apl
response:
content: '{"saleor_api_url":"https://vxcdclzonj.saleor.cloud/graphql","token":"vxhxemgqtpvvmzgcrxxetsrecvmocg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fxeyumrwrg","domain":"vxcdclzonj.saleor.cloud"}'
headers:
Connection:
- keep-alive
Content-Length:
- '328'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:41 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 201
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl?limit=2&offset=0
response:
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=2","previous":null,"results":[{"saleor_api_url":"https://sbnpsagopi.saleor.cloud/graphql","token":"lbnetlmnjviofvjvleoyhotmbsaheg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"jrlyhkvronpcuskqfhnhbbpjtvcrce\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"uzfbqpkchx","domain":"sbnpsagopi.saleor.cloud"},{"saleor_api_url":"https://msltedyydo.saleor.cloud/graphql","token":"etqfdiojfvufyfpylknharwbkkhcio","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"msfmpvhxqiqptpcfpvomenrslrmpwf\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fzglnnohvi","domain":"msltedyydo.saleor.cloud"}]}'
headers:
Connection:
- keep-alive
Content-Length:
- '779'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:41 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl?limit=2&offset=2
response:
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=4","previous":"http://apl.example.com/api/v1/apl?limit=2","results":[{"saleor_api_url":"https://rodnbaorcb.saleor.cloud/graphql","token":"nyqokxizwonkuqqexhktbvgussupaz","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"djnhwjcbtrydjfvpeltmzglfcsgngd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"lcedtozpeo","domain":"rodnbaorcb.saleor.cloud"},{"saleor_api_url":"https://vjoxywqxno.saleor.cloud/graphql","token":"uagntnmzugcynzcblilonyguxjowjq","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"pqimouklxipopspcwrxlzxmzftnxsc\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"vxrgitlduz","domain":"vjoxywqxno.saleor.cloud"}]}'
headers:
Connection:
- keep-alive
Content-Length:
- '839'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:42 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl?limit=2&offset=4
response:
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=6","previous":"http://apl.example.com/api/v1/apl?limit=2&offset=2","results":[{"saleor_api_url":"https://txjaskukcx.saleor.cloud/graphql","token":"rzbhsxcngssozsamgksgqzsyykbvws","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"bbslmktufyfdtzlfaxrqzlcmoslbeo\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"mtljtfhfjd","domain":"txjaskukcx.saleor.cloud"},{"saleor_api_url":"https://wptoxotfln.saleor.cloud/graphql","token":"bfuugsnzlrmbtbfjhbsfduslfjbmty","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"nclygpffmzyoykbffswnequwmdmdhd\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"krqomgdnue","domain":"wptoxotfln.saleor.cloud"}]}'
headers:
Connection:
- keep-alive
Content-Length:
- '848'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:42 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl?limit=2&offset=6
response:
content: '{"count":10,"next":"http://apl.example.com/api/v1/apl?limit=2&offset=8","previous":"http://apl.example.com/api/v1/apl?limit=2&offset=4","results":[{"saleor_api_url":"https://nxujxjrudl.saleor.cloud/graphql","token":"vmwhdquypdsmsgjlcgtrqacmzbaunk","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"fxblkupzfagejghhmtrstnryaeadab\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"qtpthrjavf","domain":"nxujxjrudl.saleor.cloud"},{"saleor_api_url":"https://vyyfdwmcav.saleor.cloud/graphql","token":"wmsookfbmuopwwlvceiifcoaeicqje","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ydrdjwtzkzasiuyqnqewbubtbugrag\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"nxvftnbrfb","domain":"vyyfdwmcav.saleor.cloud"}]}'
headers:
Connection:
- keep-alive
Content-Length:
- '848'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:42 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl?limit=2&offset=8
response:
content: '{"count":10,"next":null,"previous":"http://apl.example.com/api/v1/apl?limit=2&offset=6","results":[{"saleor_api_url":"https://ejwnealqvw.saleor.cloud/graphql","token":"teovwyfkqvpebpsmfpwigpqlusyrme","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"ukxkebobvkqqbmtyzcwxbebalnhvps\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"dpjoofueam","domain":"ejwnealqvw.saleor.cloud"},{"saleor_api_url":"https://vxcdclzonj.saleor.cloud/graphql","token":"vxhxemgqtpvvmzgcrxxetsrecvmocg","jwks":"{\"keys\":
[{\"kty\": \"RSA\", \"key_ops\": [\"verify\"], \"n\": \"aofvflmughkjsiebpnlkakvjqywsbn\",
\"e\": \"AQAB\", \"use\": \"sig\", \"kid\": \"1\"}]}","saleor_app_id":"fxeyumrwrg","domain":"vxcdclzonj.saleor.cloud"}]}'
headers:
Connection:
- keep-alive
Content-Length:
- '779'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 17:07:43 GMT
allow:
- GET, POST, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 200
version: 1

View file

@ -0,0 +1,44 @@
interactions:
- request:
body: ''
headers:
accept:
- '*/*'
accept-encoding:
- gzip, deflate
authorization:
- Bearer access.token
connection:
- keep-alive
host:
- apl.example.com
user-agent:
- python-httpx/0.23.3
method: GET
uri: https://apl.example.com/api/v1/apl/aHR0cHM6Ly9ub24uZXhpc3RpbmcuZG9tYWlu
response:
content: '{"detail":"Not found."}'
headers:
Connection:
- keep-alive
Content-Length:
- '23'
Content-Type:
- application/json
Date:
- Sun, 19 Feb 2023 16:59:27 GMT
allow:
- GET, PUT, PATCH, DELETE, HEAD, OPTIONS
referrer-policy:
- same-origin
server:
- uvicorn
vary:
- Origin
x-content-type-options:
- nosniff
x-frame-options:
- DENY
http_version: HTTP/1.1
status_code: 404
version: 1

View file

@ -0,0 +1,72 @@
import random
import string
import pytest
from ..common import AplEntity
_SEED = 10
def _random_str(size: int, uppercase=False) -> str:
letters = string.ascii_uppercase if uppercase else string.ascii_lowercase
return "".join(random.choice(letters) for _ in range(size))
@pytest.fixture
def anyio_backend():
return "asyncio", {"use_uvloop": True}
@pytest.fixture(scope="module")
def vcr_config():
return {
"match_on": [
"method",
"scheme",
"host",
"port",
"path",
"query",
"headers",
"body",
]
}
jwks_template = """
{{
"keys": [
{{
"kty": "RSA",
"key_ops": ["verify"],
"n": "{key_n}",
"e": "AQAB",
"use": "sig",
"kid": "1"
}}
]
}}
"""
@pytest.fixture(scope="session")
def entities_factory():
def factory(size=10):
random.seed(_SEED)
return [
AplEntity(
saleor_api_url=f"https://{_random_str(10)}.saleor.cloud/graphql",
app_id=_random_str(10),
app_token=_random_str(30),
jwks=jwks_template.format(key_n=_random_str(30)),
)
for _ in range(size)
]
return factory
@pytest.fixture(scope="session")
def entity(entities_factory) -> AplEntity:
return entities_factory()[0]

View file

@ -0,0 +1,134 @@
from pathlib import Path
import pytest
from ..backends import FileAplBackend, MemAplBackend, RestAplBackend
from ..common import AplKeyError, NotConfiguredError
from ..wrapper import AplClient
pytestmark = pytest.mark.anyio
@pytest.fixture
def mem_apl_client():
client = AplClient()
client.setup("mem://")
return client
@pytest.fixture
def file_apl_client():
path_string = ".test_fileApl.json"
apl_file = Path(path_string)
client = AplClient()
client.setup(f"file://{path_string}")
yield client
if apl_file.exists():
apl_file.unlink()
@pytest.fixture
def rest_apl_client():
client = AplClient()
client.setup("https://access.token@apl.example.com")
return client
apl_clients = ["mem_apl_client", "file_apl_client", "rest_apl_client"]
def test_parse_settings_url_mem_backend():
backend = AplClient.parse_settings_url("mem://")
assert isinstance(backend, MemAplBackend)
@pytest.mark.parametrize(
"url,path",
[
("file://.fileApl.json", ".fileApl.json"),
("file://path/to/fileApl.json", "path/to/fileApl.json"),
],
)
def test_parse_settings_url_file_backend(url, path):
backend = AplClient.parse_settings_url(url)
assert isinstance(backend, FileAplBackend)
assert backend.path == Path(path)
@pytest.mark.parametrize(
"url,apl_url,token",
[
(
"https://86b0bb44ee7e488d9cc3949b78b0a3ac.6dnRhRlMhhtjHXwBh3f3lJkmz4opGX7EInHKvxlMyPq6T5Y7@apl.example.com",
"https://apl.example.com",
"86b0bb44ee7e488d9cc3949b78b0a3ac.6dnRhRlMhhtjHXwBh3f3lJkmz4opGX7EInHKvxlMyPq6T5Y7",
),
(
"http://access.token@localhost:8000",
"http://localhost:8000",
"access.token",
),
],
)
def test_parse_settings_url_rest_backend(url, apl_url, token):
backend = AplClient.parse_settings_url(url)
assert isinstance(backend, RestAplBackend)
assert backend.apl_url == apl_url
assert backend.token == token
def test_parse_settings_url_not_supported_backend():
with pytest.raises(NotImplementedError):
AplClient.parse_settings_url("new-schema://path")
async def test_apl_client_no_setup():
client = AplClient()
with pytest.raises(NotConfiguredError):
await client.get("shop.saleor.cloud")
@pytest.mark.vcr
@pytest.mark.parametrize("client_fixture", apl_clients)
async def test_apl_get(client_fixture, entity, request):
apl_client: AplClient = request.getfixturevalue(client_fixture)
await apl_client.set(entity.saleor_api_url, entity)
entity_from_client = await apl_client.get(entity.saleor_api_url)
assert entity_from_client == entity.copy()
@pytest.mark.vcr
@pytest.mark.parametrize("client_fixture", apl_clients)
async def test_apl_key_error(client_fixture, request):
apl_client: AplClient = request.getfixturevalue(client_fixture)
with pytest.raises(AplKeyError):
await apl_client.get("https://non.existing.domain")
@pytest.mark.vcr
@pytest.mark.parametrize("client_fixture", apl_clients)
async def test_apl_delete(client_fixture, entity, request):
apl_client: AplClient = request.getfixturevalue(client_fixture)
await apl_client.set(entity.saleor_api_url, entity)
await apl_client.delete(entity.saleor_api_url)
with pytest.raises(AplKeyError):
await apl_client.get(entity.saleor_api_url)
@pytest.mark.vcr
@pytest.mark.parametrize("client_fixture", apl_clients)
async def test_apl_delete_non_existing(client_fixture, request):
apl_client: AplClient = request.getfixturevalue(client_fixture)
with pytest.raises(AplKeyError):
await apl_client.delete("https://non.existing.domain")
@pytest.mark.vcr
@pytest.mark.parametrize("client_fixture", apl_clients)
async def test_apl_get_all(client_fixture, entities_factory, request):
apl_client: AplClient = request.getfixturevalue(client_fixture)
entities = entities_factory()
for entity in entities:
await apl_client.set(entity.saleor_api_url, entity)
apl_entities = [entity async for entity in apl_client.get_all(page_size=2)]
assert apl_entities == [(entity.saleor_api_url, entity) for entity in entities]

View file

@ -0,0 +1,49 @@
from pathlib import Path
from urllib.parse import urlparse
from .backends import AplBackend, FileAplBackend, MemAplBackend, RestAplBackend
from .common import AplEntity, NotConfiguredError
class AplClient:
def __init__(self):
self._backend: AplBackend | None = None
@staticmethod
def parse_settings_url(url: str) -> AplBackend:
parts = urlparse(url)
if parts.scheme == "file":
path = Path(url.removeprefix("file://"))
return FileAplBackend(path)
elif parts.scheme == "mem":
return MemAplBackend()
elif parts.scheme in ["http", "https"]:
token = parts.username or ""
port = "" if parts.port is None else f":{parts.port}"
apl_url = f"{parts.scheme}://{parts.hostname}{port}{parts.path}"
return RestAplBackend(apl_url=apl_url, token=token)
raise NotImplementedError()
def setup(self, url: str):
self._backend = self.parse_settings_url(url)
@property
def backend(self) -> AplBackend:
if self._backend is None:
raise NotConfiguredError(
"Run `apl_client.setup(...)` before using apl_client"
)
return self._backend
async def get(self, key: str) -> AplEntity:
return await self.backend.get(key)
async def set(self, key: str, value: AplEntity):
return await self.backend.set(key, value)
async def delete(self, key: str):
return await self.backend.delete(key)
async def get_all(self, page_size=100):
async for item in self.backend.get_all(page_size=page_size):
yield item

View file

@ -0,0 +1,131 @@
import logging
from fastapi import Depends, FastAPI, Request, status
from starlette.middleware.cors import CORSMiddleware
from .api import graphql_app
from .apl import AplEntity, apl_client
from .deps import (
ApiDependencies,
WebhookDependencies,
saleor_api_url_header,
verify_saleor_api_url,
)
from .integrations.datadog import DataDogClientError
from .logs import configure_logging
from .payload import OBSERVABILITY_EVENTS
from .saleor.client import GraphQLError, SaleorClient
from .saleor.common import InstallData, LazyUrl
from .settings import manifest, settings
from .utils import get_base_url
logger = logging.getLogger(__name__)
configure_logging(settings.debug)
apl_client.setup(settings.apl_url)
app = FastAPI(openapi_url="/api/openapi.json")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_headers=["*"],
allow_methods=["OPTIONS", "GET", "POST", "DELETE"],
)
@app.get("/manifest", status_code=status.HTTP_200_OK, tags=["installation-handshake"])
async def show_manifest(request: Request):
manifest_copy = manifest.copy()
for name, field in manifest_copy:
if isinstance(field, LazyUrl):
setattr(manifest_copy, name, field(request))
for extension in manifest_copy.extensions:
if isinstance(extension.url, LazyUrl):
extension.url = extension.url(request)
for webhook in manifest_copy.webhooks:
if isinstance(webhook.target_url, LazyUrl):
webhook.target_url = webhook.target_url(request)
return manifest_copy
@app.post(
"/install",
status_code=status.HTTP_200_OK,
tags=["installation-handshake"],
name="install",
)
async def install(
data: InstallData,
request: Request,
api_url=Depends(saleor_api_url_header),
_valid=Depends(verify_saleor_api_url),
):
client = SaleorClient(api_url, "test", data.auth_token)
try:
app_info = await client.app_info()
target_url = get_base_url(request).replace(path="webhooks")
await client.create_webhook(
target_url=str(target_url),
events=["OBSERVABILITY"],
name="OBSERVABILITY",
)
jwks = await client.get_jwks()
entity = AplEntity(
saleor_api_url=api_url,
app_id=app_info.id,
app_token=data.auth_token,
jwks=jwks,
)
await apl_client.set(api_url, entity)
except GraphQLError:
return {"error": {"message": "Wrong app token"}}
return {}
@app.post(
"/webhooks",
status_code=status.HTTP_200_OK,
tags=["webhooks"],
name="webhooks",
)
async def handle_observability_events(
payloads: list[OBSERVABILITY_EVENTS],
commons: WebhookDependencies = Depends(),
):
metadata = await commons.manager.get_metadata()
if not metadata.datadog or not metadata.datadog.active:
logger.warning(
"DataDog integration inactive or not configured. Dropping %s events",
len(payloads),
)
return
credentials = metadata.datadog.credentials
try:
logger.info("Sending %s events to DataDog", len(payloads))
await commons.datadog_client.send_logs(
commons.saleor_data.saleor_api_url, credentials, payloads
)
except DataDogClientError:
logger.warning("Sending logs to DataDog failed. Deactivating integration")
metadata.datadog.active = False
metadata.datadog.error = "Wrong credentials. Integration deactivated"
await commons.manager.save_private_metadata(metadata)
@app.get("/health", status_code=status.HTTP_200_OK, tags=["health"], name="health")
async def health():
return {"status": "ok"}
@app.get("/graphql")
async def graphiql(request: Request):
return await graphql_app.render_playground(request)
@app.post("/graphql")
async def graphql(request: Request, commons: ApiDependencies = Depends()):
request.state.api_context = commons
return await graphql_app.graphql_http_server(request)

View file

@ -0,0 +1,143 @@
import logging
from fastapi import Depends, Header, HTTPException, Request
from jwt.api_jwk import PyJWKSet
from .apl import AplEntity, AplError, AplKeyError, apl_client
from .integrations.datadog import DataDogApiClient, DataDogClient, TestDataDogClient
from .saleor import (
SALEOR_API_URL_HEADER,
SALEOR_SIGNATURE_HEADER,
SALEOR_TOKEN_HEADER,
SaleorToken,
)
from .saleor.crypto import decode_webhook_payload
from .saleor.manager import SaleorManager
from .schema import Metadata
from .settings import settings
logger = logging.getLogger(__name__)
async def saleor_api_url_header(
api_url: str | None = Header(None, alias=SALEOR_API_URL_HEADER)
) -> str:
if not api_url:
msg = f"Missing {SALEOR_API_URL_HEADER.upper()} header."
logger.warning(msg)
raise HTTPException(status_code=400, detail=msg)
return api_url
async def verify_saleor_api_url(api_url: str = Depends(saleor_api_url_header)):
# TODO implement
if api_url:
return True
raise HTTPException(
status_code=400,
detail=f"Provided url is invalid or environment {api_url} not allowed.",
)
async def saleor_token_header(
token: str | None = Header(None, alias=SALEOR_TOKEN_HEADER)
) -> str:
if not token:
msg = f"Missing {SALEOR_TOKEN_HEADER.upper()} header."
logger.warning(msg)
raise HTTPException(status_code=400, detail=msg)
return token
async def apl_entity(
api_url: str = Depends(saleor_api_url_header),
_verify=Depends(verify_saleor_api_url),
) -> AplEntity:
try:
return await apl_client.get(api_url)
except AplKeyError:
msg = f"App needs to be installed before use. First, install the app in Saleor at {api_url}."
logger.warning(msg)
raise HTTPException(status_code=400, detail=msg) # noqa: 904
except AplError as error:
logging.error("APL error %r", error)
raise HTTPException( # noqa: 904
status_code=500, detail="App error. Try again later."
)
async def saleor_token(
token_str: str = Depends(saleor_token_header),
saleor_data: AplEntity = Depends(apl_entity),
) -> SaleorToken:
token = SaleorToken(token_str, saleor_data.jwks)
# TODO handle jwks reload
return token
async def verify_is_stuff_user(token: SaleorToken = Depends(saleor_token)) -> bool:
if not token.is_staff_user():
HTTPException(
status_code=400, detail="Only staff user can perform this operation."
)
return True
async def get_saleor_signature(
saleor_signature: str | None = Header(None, alias=SALEOR_SIGNATURE_HEADER)
):
return saleor_signature
async def verify_webhook_signature(
request: Request,
jws: str = Depends(get_saleor_signature),
saleor_data: AplEntity = Depends(apl_entity),
):
saleor_jwks = PyJWKSet.from_dict(saleor_data.jwks)
return decode_webhook_payload(
jws=jws, jwks=saleor_jwks, webhook_payload=await request.body()
)
async def saleor_manager(saleor_data: AplEntity = Depends(apl_entity)):
return SaleorManager[Metadata](
saleor_data.saleor_api_url,
saleor_data.app_id,
saleor_data.app_token,
Metadata,
)
async def datadog_client() -> DataDogClient:
if settings.mock_datadog_client:
return TestDataDogClient()
return DataDogApiClient()
class ApiDependencies:
def __init__(
self,
token: SaleorToken = Depends(saleor_token),
saleor_data: AplEntity = Depends(apl_entity),
manager: SaleorManager[Metadata] = Depends(saleor_manager),
datadog_client: DataDogClient = Depends(datadog_client),
_verify_is_stuff_user=Depends(verify_is_stuff_user),
):
self.saleor_data = saleor_data
self.token = token
self.manager = manager
self.datadog_client = datadog_client
class WebhookDependencies:
def __init__(
self,
saleor_data: AplEntity = Depends(apl_entity),
manager: SaleorManager[Metadata] = Depends(saleor_manager),
datadog_client: DataDogClient = Depends(datadog_client),
_verify_webhook_signature=Depends(verify_webhook_signature),
):
self.saleor_data = saleor_data
self.manager = manager
self.datadog_client = datadog_client

View file

@ -0,0 +1,145 @@
import logging
from typing import Any, Awaitable, cast
from datadog_api_client.exceptions import ApiException, ForbiddenException
from datadog_api_client.v1.api.authentication_api import AuthenticationApi
from datadog_api_client.v2 import AsyncApiClient, Configuration
from datadog_api_client.v2.api.logs_api import LogsApi
from datadog_api_client.v2.model.content_encoding import ContentEncoding
from datadog_api_client.v2.model.http_log import HTTPLog
from pydantic import BaseModel
from ..payload import OBSERVABILITY_EVENTS
from .logs import generate_logs
LOGS_ENCODING = ContentEncoding("gzip")
logger = logging.getLogger(__name__)
LOGS_ERRORS = {
400: "Bad request (likely an issue in the payload formatting)",
401: "Unauthorized (likely a missing API Key)",
403: "Permission issue (likely using an invalid API Key)",
408: "Request Timeout, request should be retried after some time",
413: "Payload too large (batch is above 5MB uncompressed)",
429: "Too Many Requests, request should be retried after some time",
500: "Internal Server Error, the server encountered an unexpected condition that "
"prevented it from fulfilling the request, request should be retried after some time",
503: "Service Unavailable, the server is not ready to handle the request "
"probably because it is overloaded, request should be retried after some time",
}
class DataDogClientError(Exception):
pass
class DataDogCredsRejectedError(DataDogClientError):
"""DataDog credentials were rejected"""
class DatadogCredentials(BaseModel):
site: str
api_key: str
class DataDogClient:
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
raise NotImplementedError()
async def send_logs(
self,
saleor_api_url: str,
credentials: DatadogCredentials,
logs: list[OBSERVABILITY_EVENTS],
):
raise NotImplementedError()
class TestDataDogClient(DataDogClient):
_good_creds = DatadogCredentials(
site="US1", api_key="156e22d50c4e8b6816e1fd4794d3fd8c"
)
_failing_creds = DatadogCredentials(
site="EU1", api_key="156e22d50c4e8b6816e1fd4794d3fd8c"
)
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
return credentials == self._good_creds or credentials == self._failing_creds
async def send_logs(
self,
saleor_api_url: str,
credentials: DatadogCredentials,
logs: list[OBSERVABILITY_EVENTS],
):
if credentials == self._good_creds:
return
raise DataDogCredsRejectedError()
class DataDogApiClient(DataDogClient):
_site_map = {
"US1": "datadoghq.com",
"US3": "us3.datadoghq.com",
"US5": "us5.datadoghq.com",
"EU1": "datadoghq.eu",
"US1_FED": "ddog-gov.com",
}
@classmethod
def _get_config(cls, credentials: DatadogCredentials) -> Configuration:
configuration = Configuration()
configuration.api_key["apiKeyAuth"] = credentials.api_key
configuration.server_variables["site"] = cls._site_map[credentials.site]
return configuration
async def validate_credentials(self, credentials: DatadogCredentials) -> bool:
config = self._get_config(credentials)
async with AsyncApiClient(config) as api_client:
api_instance = AuthenticationApi(api_client)
try:
response = await cast(Awaitable[Any], api_instance.validate())
return response.valid
except ForbiddenException:
pass
except ApiException as exp:
logger.error(
"DataDog validate_credentials ApiException[%s]",
exp.status,
extra={
"status_code": exp.status,
"response_headers": dict(exp.headers) if exp.headers else None,
},
)
return False
async def send_logs(
self,
saleor_api_url: str,
credentials: DatadogCredentials,
logs: list[OBSERVABILITY_EVENTS],
):
config = self._get_config(credentials)
logs = generate_logs(logs, saleor_api_url)
async with AsyncApiClient(config) as api_client:
api_instance = LogsApi(api_client)
try:
await cast(
Awaitable[Any],
api_instance.submit_log(
body=HTTPLog(logs), content_encoding=LOGS_ENCODING
),
)
except ApiException as exp:
error_msg = LOGS_ERRORS.get(exp.status)
logger.error(
"DataDog send_logs ApiException[%s]",
error_msg,
extra={
"status_code": exp.status,
"response_headers": dict(exp.headers) if exp.headers else None,
},
)
except Exception:
logger.error("DataDog send_logs Unknown error")

View file

@ -0,0 +1,291 @@
from datetime import datetime
from enum import Enum
from datadog_api_client.v2.model.http_log_item import HTTPLogItem
from pydantic import BaseModel
from ua_parser import user_agent_parser # type: ignore
from ..payload import (
OBSERVABILITY_EVENTS,
ApiCallPayload,
EventDeliveryAttemptPayload,
JsonTruncText,
)
from ..utils import parse_headers
class EventDeliveryStatus(str, Enum):
pending = "pending"
success = "success"
failed = "failed"
class LogLevel(str, Enum):
info = "INFO"
warn = "WARN"
error = "ERROR"
log_level_map = {
EventDeliveryStatus.pending.value: LogLevel.warn,
EventDeliveryStatus.success.value: LogLevel.info,
EventDeliveryStatus.failed.value: LogLevel.error,
}
class DataDogLogUseragentDetails(BaseModel):
os: dict[str, str | None]
browser: dict[str, str | None]
device: dict[str, str | None]
class DataDogLogNetwork(BaseModel):
bytes_read: int | None
bytes_written: int | None
class DataDogLogEvent(BaseModel):
name: str
outcome: str | None
class DataDogLogHttp(BaseModel):
url: str
request_id: str | None
referer: str | None
status_code: str | None
method: str | None
useragent: str | None
useragent_details: DataDogLogUseragentDetails | None
class DataDogLog(BaseModel):
ddsource: str = "saleor"
service: str
timestamp: float
level: LogLevel = LogLevel.info
ddtags: str | None
http: DataDogLogHttp
network: DataDogLogNetwork
evt: DataDogLogEvent
duration: int | None
class GraphQLOperation(BaseModel):
name: str | None
operation_type: str
query: str | None
result: str | None
result_invalid: bool
truncated: bool
class EventRequest(BaseModel):
headers: dict[str, str] = {}
class EventResponse(BaseModel):
headers: dict[str, str] = {}
body: JsonTruncText | None
class App(BaseModel):
id: str
name: str
class ApiCall(BaseModel):
request_headers: dict[str, str] = {}
response_headers: dict[str, str] = {}
gql_operations: list[GraphQLOperation]
app: App | None
class ApiCallLog(DataDogLog):
saleor: ApiCall
class Webhook(BaseModel):
id: str
name: str
target_url: str
subscription_query: JsonTruncText | None
class EventDeliveryPayload(BaseModel):
content_length: int
body: JsonTruncText
class EventDelivery(BaseModel):
id: str
status: str
event_type: str
event_sync: bool
payload: EventDeliveryPayload
class EventDeliveryAttempt(BaseModel):
id: str
status: str
next_retry: datetime | None
request_headers: dict[str, str] = {}
response_headers: dict[str, str] = {}
response_body: JsonTruncText | None
class EventDeliveryAttemptData(BaseModel):
event_delivery_attempt: EventDeliveryAttempt
event_delivery: EventDelivery
webhook: Webhook
app: App
class EventDeliveryAttemptLog(DataDogLog):
saleor: EventDeliveryAttemptData
def http_status_ok(status_code: int | None):
if status_code is None or status_code >= 400:
return False
return True
def parse_useragent(useragent: str | None) -> DataDogLogUseragentDetails | None:
if not useragent:
return None
parsed = user_agent_parser.Parse(useragent)
return DataDogLogUseragentDetails(
os=parsed["os"],
browser=parsed["user_agent"],
device=parsed["device"],
)
def convert_duration(duration: float | None) -> float | None:
return duration * 1_000_000_000 if duration else None
def generate_api_call_log(payload: ApiCallPayload, saleor_domain: str) -> HTTPLogItem:
request_headers = parse_headers(payload.request.headers)
response_headers = parse_headers(payload.response.headers)
gql_operations: list[GraphQLOperation] = []
for op in payload.gql_operations:
gql_operations.append(
GraphQLOperation(
name=op.name.text if op.name else None,
operation_type=op.operation_type,
query=op.query.text if op.query else None,
result=op.result.text if op.result else None,
result_invalid=op.result_invalid,
truncated=any(
[
op.name.truncated if op.name else False,
op.query.truncated if op.query else False,
op.result.truncated if op.result else False,
]
),
)
)
level = (
LogLevel.info
if http_status_ok(payload.response.status_code)
else LogLevel.error
)
message = ApiCallLog(
service=saleor_domain,
timestamp=payload.request.time.timestamp() * 1000,
level=level,
http=DataDogLogHttp(
url=payload.request.url,
request_id=payload.request.id,
referer=request_headers.get("Referer"),
status_code=str(payload.response.status_code),
method=payload.request.method,
useragent=request_headers.get("User-Agent"),
useragent_details=parse_useragent(request_headers.get("User-Agent")),
),
network=DataDogLogNetwork(
bytes_read=payload.request.content_length,
bytes_written=payload.response.content_length,
),
evt=DataDogLogEvent(
name=payload.event_type,
outcome="success" if level == LogLevel.info else "failure",
),
saleor=ApiCall(
gql_operations=gql_operations,
app=App(id=payload.app.id, name=payload.app.name) if payload.app else None,
request_headers=dict(request_headers),
response_headers=dict(response_headers),
),
)
return HTTPLogItem(message=message.json())
def generate_event_delivery_attempt_log(
payload: EventDeliveryAttemptPayload, saleor_domain: str
) -> HTTPLogItem:
request_headers = parse_headers(payload.request.headers)
response_headers = parse_headers(payload.response.headers)
message = EventDeliveryAttemptLog(
service=saleor_domain,
timestamp=payload.time.timestamp() * 1000,
level=log_level_map.get(payload.event_delivery.status, LogLevel.error),
duration=convert_duration(payload.duration),
http=DataDogLogHttp(
url=payload.webhook.target_url,
request_id=payload.id,
status_code=str(payload.response.status_code),
),
network=DataDogLogNetwork(
bytes_read=payload.response.content_length,
bytes_written=payload.event_delivery.payload.content_length,
),
evt=DataDogLogEvent(
name=payload.event_type,
outcome=payload.status,
),
saleor=EventDeliveryAttemptData(
event_delivery_attempt=EventDeliveryAttempt(
id=payload.id,
status=payload.status,
next_retry=payload.next_retry,
request_headers=dict(request_headers),
response_headers=dict(response_headers),
response_body=payload.response.body,
),
app=App(id=payload.app.id, name=payload.app.name),
event_delivery=EventDelivery(
id=payload.event_delivery.id,
status=payload.event_delivery.status,
event_type=payload.event_delivery.event_type,
event_sync=payload.event_delivery.event_sync,
payload=EventDeliveryPayload(
body=payload.event_delivery.payload.body,
content_length=payload.event_delivery.payload.content_length,
),
),
webhook=Webhook(
id=payload.webhook.id,
name=payload.webhook.name,
target_url=payload.webhook.target_url,
subscription_query=payload.webhook.subscription_query,
),
),
)
return HTTPLogItem(message=message.json())
def generate_logs(
payloads: list[OBSERVABILITY_EVENTS], saleor_domain: str
) -> list[HTTPLogItem]:
log_items: list[HTTPLogItem] = []
for payload in payloads:
if isinstance(payload, ApiCallPayload):
log_items.append(generate_api_call_log(payload, saleor_domain))
elif isinstance(payload, EventDeliveryAttemptPayload):
log_items.append(
generate_event_delivery_attempt_log(payload, saleor_domain)
)
return log_items

View file

@ -0,0 +1,62 @@
import logging.config
def configure_logging(debug=False):
config = {
"version": 1,
"disable_existing_loggers": False,
"root": {
"level": "INFO",
"handlers": ["default"],
},
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"datefmt": "%Y-%m-%dT%H:%M:%SZ",
"format": (
"%(asctime)s %(levelname)s %(lineno)s %(message)s %(name)s "
+ "%(pathname)s %(process)d %(threadName)s"
),
},
"verbose": {
"format": (
"%(levelname)s %(name)s %(message)s [PID:%(process)d:%(threadName)s]"
)
},
"uvicorn": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s [PID:%(process)d:%(threadName)s]",
"use_colors": None,
},
},
"handlers": {
"default": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose" if debug else "json",
},
"uvicorn": {
"class": "logging.StreamHandler",
"formatter": "uvicorn" if debug else "json",
},
"null": {
"class": "logging.NullHandler",
},
},
"loggers": {
"uvicorn": {
"propagate": False,
"handlers": ["uvicorn"],
"level": "INFO",
},
"uvicorn.access": {
"propagate": False,
"handlers": ["null"],
},
"saleor_app_observability": {
"level": "DEBUG" if debug else "INFO",
"propagate": True,
},
},
}
logging.config.dictConfig(config)

View file

@ -0,0 +1,93 @@
from datetime import datetime
from typing import Any, Literal
from .utils import HttpHeaders, JsonBaseModel
class App(JsonBaseModel):
id: str
name: str
class ApiCallRequest(JsonBaseModel):
id: str
method: str
url: str
time: datetime
headers: HttpHeaders
content_length: int
class ApiCallResponse(JsonBaseModel):
headers: HttpHeaders
status_code: int | None
content_length: int
class JsonTruncText(JsonBaseModel):
text: str
truncated: bool
class GraphQLOperation(JsonBaseModel):
name: JsonTruncText | None
operation_type: str | None
query: JsonTruncText | None
result: JsonTruncText | None
result_invalid: bool
class ApiCallPayload(JsonBaseModel):
event_type: Literal["api_call"]
request: ApiCallRequest
response: ApiCallResponse
gql_operations: list[GraphQLOperation]
app: App | None
class EventDeliveryAttemptRequest(JsonBaseModel):
headers: HttpHeaders
class EventDeliveryAttemptResponse(JsonBaseModel):
headers: HttpHeaders
status_code: int | None
content_length: int
body: JsonTruncText
class EventDeliveryPayload(JsonBaseModel):
content_length: int
body: JsonTruncText
class EventDelivery(JsonBaseModel):
id: str
status: str
event_type: str
event_sync: bool
payload: EventDeliveryPayload
class Webhook(JsonBaseModel):
id: str
name: str
target_url: str
subscription_query: JsonTruncText | None
class EventDeliveryAttemptPayload(JsonBaseModel):
event_type: Literal["event_delivery_attempt"]
id: str
time: datetime
duration: float | None
status: str
next_retry: datetime | None
request: EventDeliveryAttemptRequest
response: EventDeliveryAttemptResponse
event_delivery: EventDelivery
webhook: Webhook
app: App
OBSERVABILITY_EVENTS = ApiCallPayload | EventDeliveryAttemptPayload | dict[str, Any]

View file

@ -0,0 +1,13 @@
from .common import (
SALEOR_API_URL_HEADER,
SALEOR_SIGNATURE_HEADER,
SALEOR_TOKEN_HEADER,
SaleorToken,
)
__all__ = [
"SALEOR_TOKEN_HEADER",
"SALEOR_API_URL_HEADER",
"SALEOR_SIGNATURE_HEADER",
"SaleorToken",
]

View file

@ -0,0 +1,103 @@
from typing import Any, Dict, Optional, Sequence
from urllib.parse import urlparse, urlunparse
import httpx
from .graphql import (
CREATE_WEBHOOK,
DELETE_PRIVATE_METADATA,
GET_APP_INFO,
UPDATE_PRIVATE_METADATA,
)
from .utils import JsonBaseModel
class WebhookInfo(JsonBaseModel):
id: str
target_url: str
is_active: bool
class AppInfo(JsonBaseModel):
id: str
webhooks: list[WebhookInfo]
private_metafields: dict[str, str]
class GraphQLError(Exception):
"""
Raised on Saleor GraphQL errors
"""
def __init__(
self,
errors: Sequence[Dict[str, Any]],
response_data: Optional[Dict[str, Any]] = None,
):
self.errors = errors
self.response_data = response_data
def __str__(self):
return (
f"GraphQLError: {', '.join([error['message'] for error in self.errors])}."
)
class SaleorClient:
def __init__(self, saleor_api_url: str, user_agent, auth_token=None):
self.saleor_api_url = saleor_api_url
self.headers = {"User-Agent": user_agent}
if auth_token:
self.headers["Authorization"] = f"Bearer {auth_token}"
async def get_jwks(self) -> dict[str, Any]:
parts = urlparse(self.saleor_api_url)
jwks_url = urlunparse(parts._replace(path="/.well-known/jwks.json"))
async with httpx.AsyncClient() as client:
res = await client.get(jwks_url)
return res.json()
async def execute(self, query, variables=None):
async with httpx.AsyncClient() as client:
data = {"query": query, "variables": variables}
res = await client.post(
self.saleor_api_url,
json=data,
headers=self.headers,
)
res_data = res.json()
if errors := res_data.get("errors"):
raise GraphQLError(errors=errors, response_data=res_data.get("data"))
return res_data["data"]
async def app_info(self) -> AppInfo:
result = await self.execute(GET_APP_INFO)
return AppInfo.parse_obj(result["app"])
async def update_private_metadata(self, app_id: str, metadata: dict[str, str]):
metadata_input = [{"key": key, "value": val} for key, val in metadata.items()]
await self.execute(
UPDATE_PRIVATE_METADATA,
variables={"appId": app_id, "metadata": metadata_input},
)
async def delete_private_metadata(self, app_id: str, keys: set[str]):
await self.execute(
DELETE_PRIVATE_METADATA, variables={"appId": app_id, "keys": list(keys)}
)
async def create_webhook(
self,
target_url: str,
events: list[str],
name: str,
):
webhook_input = {
"targetUrl": target_url,
"events": events,
"name": name,
}
await self.execute(
CREATE_WEBHOOK,
variables={"input": webhook_input},
)

View file

@ -0,0 +1,150 @@
from enum import Enum
from typing import Any
from fastapi import Request
from jwt.api_jwk import PyJWKSet
from pydantic import AnyHttpUrl, BaseModel, Field
from starlette.routing import NoMatchFound
from ..utils import get_base_url
from .crypto import decode_jwt
SALEOR_API_URL_HEADER = "saleor-api-url"
SALEOR_TOKEN_HEADER = "authorization-bearer"
SALEOR_SIGNATURE_HEADER = "saleor-signature"
class SaleorAppError(Exception):
"""Generic Saleor App Error, all framework errros inherit from this"""
class InstallAppError(SaleorAppError):
"""Install App error"""
class ConfigurationError(SaleorAppError):
"""App is misconfigured"""
class InstallData(BaseModel):
auth_token: str
class SaleorPermissions(str, Enum):
MANAGE_OBSERVABILITY = "MANAGE_OBSERVABILITY"
class LazyUrl(str):
"""
Used to declare a fully qualified url that is to be resolved when the
request is available.
"""
def __init__(self, name: str):
self.name = name
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, v):
return v
def resolve(self):
return self.request.url_for(self.name)
def __call__(self, request: Request):
self.request = request
try:
return self.resolve()
except NoMatchFound:
raise ConfigurationError(
f"Failed to resolve a lazy url, check if an endpoint named '{self.name}' is defined."
) from None
def __hash__(self):
return hash(self.name)
def __eq__(self, other):
return self.name == other.name
def __ne__(self, other):
return not (self.name == other.name)
def __str__(self):
return f"LazyURL('{self.name}')"
def __repr__(self):
return str(self)
class LazyAbsoluteUrl(LazyUrl):
def resolve(self):
base_url = get_base_url(self.request)
return str(base_url.replace(path=self.name))
class LazyPath(LazyUrl):
"""
Much like LazyUrl but resolves only to the path part of an url.
The lazy aspect of this class is very redundant but is built like so to
maintain the same usage as the LazyUrl class.
"""
def resolve(self):
return self.request.app.url_path_for(self.name)
def __str__(self):
return f"LazyPath('{self.name}')"
class Webhook(BaseModel):
name: str
async_events: list[str] = Field(..., alias="asyncEvents")
query: str
target_url: AnyHttpUrl | LazyUrl = Field(..., alias="targetUrl")
is_active: bool = Field(..., alias="isActive")
class Config:
allow_population_by_field_name = True
class Manifest(BaseModel):
id: str
permissions: list[str]
name: str
version: str
about: str
extensions: list[Any] = []
webhooks: list[Webhook] = []
data_privacy: str = Field(..., alias="dataPrivacy")
data_privacy_url: AnyHttpUrl | LazyUrl = Field(..., alias="dataPrivacyUrl")
homepage_url: AnyHttpUrl | LazyUrl = Field(..., alias="homepageUrl")
support_url: AnyHttpUrl | LazyUrl = Field(..., alias="supportUrl")
configuration_url: AnyHttpUrl | LazyUrl | None = Field(
None, alias="configurationUrl"
)
app_url: AnyHttpUrl | LazyUrl = Field(..., alias="appUrl")
token_target_url: AnyHttpUrl | LazyUrl = Field(
LazyUrl("install"), alias="tokenTargetUrl"
)
class Config:
allow_population_by_field_name = True
class SaleorToken:
def __init__(self, token_str: str, jwks: dict[str, Any]):
self.token_str = token_str
self.jwks = PyJWKSet.from_dict(jwks)
self.jwt = decode_jwt(self.token_str, self.jwks)
def is_staff_user(self) -> bool:
return self.jwt["is_staff"]
def validate_permission(self, permissions: str | list[str]) -> bool:
return True
def validate_user_permission(self, permissions: str | list[str]) -> bool:
return True

View file

@ -0,0 +1,66 @@
from typing import Dict
from jwt.api_jwk import PyJWKSet
from jwt.api_jws import PyJWS
from jwt.api_jwt import PyJWT
class CryptoException(Exception):
"""
Base exception for the crypto module errors.
"""
class JWKSKeyMissing(CryptoException):
"""
Raised when a requested kid is missing from a keyset.
"""
class KeyIDMissing(CryptoException):
"""
Raised when a JWT without a 'kid' header is received.
"""
jwt_global_obj = PyJWT(options={"verify_signature": True})
jws_global_obj = PyJWS(options={"verify_signature": True})
get_unverified_header = jws_global_obj.get_unverified_header
def get_kid(sig_header: Dict[str, str]):
try:
return sig_header["kid"]
except KeyError as err:
raise KeyIDMissing() from err
def get_key_from_jwks(kid: str, jwks: PyJWKSet):
try:
jwks_key = jwks[kid]
except KeyError as err:
raise JWKSKeyMissing(f"The JWKS does not hold the key: {kid}") from err
return jwks_key.key
def decode_webhook_payload(jws: str, jwks: PyJWKSet, webhook_payload: bytes):
sig_header = get_unverified_header(jws)
key = get_key_from_jwks(kid=get_kid(sig_header), jwks=jwks)
return jws_global_obj.decode(
jws,
algorithms=[sig_header["alg"]],
key=key,
detached_payload=webhook_payload,
)
def decode_jwt(jwt: str, jwks: PyJWKSet):
sig_header = get_unverified_header(jwt)
key = get_key_from_jwks(kid=get_kid(sig_header), jwks=jwks)
return jwt_global_obj.decode(
jwt,
algorithms=[sig_header["alg"]],
key=key,
)

View file

@ -0,0 +1,62 @@
GET_APP_INFO = """
query GetAppInfo {
app {
id
webhooks {
id
targetUrl
isActive
}
privateMetafields
}
}
"""
UPDATE_PRIVATE_METADATA = """
mutation UpdatePrivateMetadata($appId: ID!, $metadata: [MetadataInput!]!) {
updatePrivateMetadata(id: $appId, input: $metadata) {
errors {
field
message
code
}
item {
privateMetafields
}
}
}
"""
DELETE_PRIVATE_METADATA = """
mutation DeletePrivateMetadata($appId: ID!, $keys: [String!]!) {
deletePrivateMetadata(id: $appId, keys: $keys) {
errors {
field
message
code
}
item {
privateMetafields
}
}
}
"""
CREATE_WEBHOOK = """
mutation WebhookCreate($input: WebhookCreateInput!) {
webhookCreate(input: $input) {
webhookErrors {
field
message
code
}
webhook {
id
}
}
}
"""

View file

@ -0,0 +1,40 @@
from typing import Generic, Type, TypeVar
from .client import AppInfo, SaleorClient
from .metadata import BaseMetadata
T = TypeVar("T", bound=BaseMetadata)
class SaleorManager(Generic[T]):
def __init__(
self,
saleor_api_url: str,
app_id: str,
auth_token: str,
metadata_cls: Type[T],
user_agent="test",
):
self.client = SaleorClient(saleor_api_url, user_agent, auth_token)
self.app_id = app_id
self.metadata_cls = metadata_cls
async def get_app_info(self) -> AppInfo:
return await self.client.app_info()
async def get_metadata(self) -> T:
app_info = await self.get_app_info()
return self.metadata_cls.parse_obj(app_info.private_metafields)
async def save_private_metadata(self, metadata: T, include: set[str] | None = None):
exported = metadata.export(include)
await self.client.update_private_metadata(self.app_id, exported)
async def delete_private_metadata(self, keys: str | set[str]):
await self.client.delete_private_metadata(self.app_id, {"datadog"})
flatten_keys: set[str] = set()
if isinstance(keys, str):
keys = {keys}
for key in keys:
flatten_keys.update(self.metadata_cls.field_flatten(key))
await self.client.delete_private_metadata(self.app_id, flatten_keys)

View file

@ -0,0 +1,88 @@
import json
from typing import Any
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, root_validator, validator
from pydantic.fields import ModelField
class JsonFieldsModel(BaseModel):
@validator("*", pre=True)
def parse_field(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError:
pass
return v
class FlatMetadata(JsonFieldsModel):
pass
class BaseMetadata(JsonFieldsModel):
_flat_fields: dict[str, tuple[str, str]] = {}
@classmethod
def field_flatten(cls, field: str | ModelField) -> set[str]:
flatten_set: set[str] = set()
if isinstance(field, str):
field = cls.__fields__[field]
if issubclass(field.type_, FlatMetadata):
for sub_field in field.type_.__fields__.values():
flat_key = f"{field.name}_{sub_field.name}"
flatten_set.add(flat_key)
else:
flatten_set.add(field.name)
return flatten_set
@classmethod
def _get_flat_fields_map(cls) -> dict[str, tuple[str, str]]:
flat_fields: dict[str, tuple[str, str]] = {}
for field in cls.__fields__.values():
if issubclass(field.type_, FlatMetadata):
for sub_field in field.type_.__fields__.values():
flat_key = f"{field.name}_{sub_field.name}"
flat_fields[flat_key] = field.name, sub_field.name
return flat_fields
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._flat_fields = cls._get_flat_fields_map()
for field_name in cls.__fields__.keys():
if field_name in cls._flat_fields:
parent, subfield = cls._flat_fields[field_name]
raise ValueError(
f"{cls!r}: field {field_name} name conflict with flatten {parent}.{subfield}"
)
@staticmethod
def _encode_field(field, value):
if isinstance(value, FlatMetadata):
result = {}
for key, val in value.dict().items():
result[f"{field.name}_{key}"] = jsonable_encoder(val)
return result
return {field.name: jsonable_encoder(value)}
def export(self, include: set | None = None) -> dict[str, str]:
metadata: dict[str, Any] = {}
fields = set(self.__fields__.keys())
if include is not None:
fields = fields & include
for field_name in fields:
field, value = self.__fields__[field_name], getattr(self, field_name)
metadata.update(self._encode_field(field, value))
return {key: json.dumps(val) for key, val in metadata.items()}
@root_validator(pre=True)
def unflatten(cls, values):
unflatten_values: dict[str, Any] = {}
for key, val in values.items():
try:
parent_name, field_name = cls._flat_fields[key]
unflatten_values.setdefault(parent_name, {})[field_name] = val
except KeyError:
unflatten_values[key] = val
return unflatten_values

View file

@ -0,0 +1,81 @@
import pytest
from pydantic import BaseModel
from ..metadata import BaseMetadata, FlatMetadata
class Foo(BaseModel):
a: str
b: float | None = None
class Bar(BaseModel):
a: int
b: Foo | None = None
c: list[Foo] = []
class ExampleConfig(FlatMetadata):
login: str
password: str
more: Bar | None = None
class Metadata(BaseMetadata):
a: str | None = None
b: int | None = None
c: list[str] | None = None
d: str | None = None
e: Bar | None = None
f: ExampleConfig | None = None
g: list[Bar] = []
@pytest.fixture
def metadata():
return Metadata(
a="a",
b=3,
c=["a", "b", "c"],
e=Bar(a=0, b=Foo(a="a"), c=[Foo(a="a"), Foo(a="b")]),
f=ExampleConfig(
login="admin",
password="admin",
more=Bar(
a=1,
b=Foo(
a="a",
),
),
),
g=[Bar(a=0), Bar(a=1, b=Foo(a="a"))],
)
def test_to_metadata(metadata):
assert metadata.export() == {
"a": '"a"',
"b": "3",
"c": '["a", "b", "c"]',
"d": "null",
"e": '{"a": 0, "b": {"a": "a", "b": null}, "c": [{"a": "a", "b": null}, {"a": "b", "b": null}]}',
"f_login": '"admin"',
"f_password": '"admin"',
"f_more": '{"a": 1, "b": {"a": "a", "b": null}, "c": []}',
"g": '[{"a": 0, "b": null, "c": []}, {"a": 1, "b": {"a": "a", "b": null}, "c": []}]',
}
def test_metadata_export_reversible(metadata):
exported = metadata.export()
assert Metadata.parse_obj(exported) == metadata
def test_metadata_flatten_field(metadata):
assert metadata.field_flatten("a") == {"a"}
assert metadata.field_flatten("f") == {"f_login", "f_password", "f_more"}
assert metadata.field_flatten(metadata.__fields__["f"]) == {
"f_login",
"f_password",
"f_more",
}

View file

@ -0,0 +1,12 @@
from pydantic import BaseModel
def to_camel(snake_str: str) -> str:
components = snake_str.split("_")
return components[0] + "".join(x.capitalize() if x else "_" for x in components[1:])
class JsonBaseModel(BaseModel):
class Config:
alias_generator = to_camel
allow_population_by_field_name = True

View file

@ -0,0 +1,62 @@
interface Configuration {
active: Boolean!
error: String
}
type ConfigurationError {
field: String
message: String!
}
# Integration specific
# Datadog
enum DatadogSite{
US1
US3
US5
EU1
US1_FED
}
type DataDogCredentials {
site: DatadogSite!
apiKeyLast4: String!
}
type DatadogConfig implements Configuration {
active: Boolean!
error: String
credentials: DataDogCredentials!
}
type Integrations {
datadog: DatadogConfig
}
input DataDogCredentialsInput {
site: DatadogSite!
apiKey: String!
}
input DatadogConfigInput {
active: Boolean
credentials: DataDogCredentialsInput
}
type DataDogConfigMutationResult {
errors: [ConfigurationError!]!
datadog: DatadogConfig
}
# End integrations
type Query {
integrations: Integrations!
}
type Mutation {
updateDatadogConfig(input: DatadogConfigInput!): DataDogConfigMutationResult!
deleteDatadogConfig: DataDogConfigMutationResult!
}

View file

@ -0,0 +1,12 @@
from .integrations.datadog import DatadogCredentials
from .saleor.metadata import BaseMetadata, FlatMetadata
class DatadogConfig(FlatMetadata):
active: bool = False
error: str | None = None
credentials: DatadogCredentials
class Metadata(BaseMetadata):
datadog: DatadogConfig | None = None

View file

@ -0,0 +1,41 @@
from pathlib import Path
from pydantic import BaseSettings
from . import __version__ as app_version
from .saleor.common import LazyAbsoluteUrl, Manifest, SaleorPermissions
base_dir = Path(__file__).resolve().parent
class AppSettings(BaseSettings):
debug: bool = True
apl_url: str = f"file://{base_dir/'.fileApl.json'}"
mock_datadog_client = False
allowed_domains: set[str] = {"*"}
forbidden_domains: set[str] = set()
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = AppSettings()
manifest = Manifest(
id="saleor-app-monitoring",
name="Monitoring",
version=app_version,
about="Saleor Monitoring app",
data_privacy="",
app_url=LazyAbsoluteUrl("/"),
configuration_url=LazyAbsoluteUrl("/configuration"),
data_privacy_url="https://saleor.io/legal/privacy", # noqa
homepage_url="https://saleor.io/", # noqa
support_url="https://github.com/saleor", # noqa
token_target_url=LazyAbsoluteUrl("install"),
permissions=[SaleorPermissions.MANAGE_OBSERVABILITY],
extensions=[],
webhooks=[],
)

View file

@ -0,0 +1,20 @@
import pytest
from httpx import AsyncClient
from ..app import app
@pytest.fixture
def anyio_backend():
return "asyncio", {"use_uvloop": True}
@pytest.fixture
def observability_app():
return app
@pytest.fixture
async def client(observability_app):
async with AsyncClient(app=observability_app, base_url="http://test") as client:
yield client

View file

@ -0,0 +1,8 @@
import pytest
@pytest.mark.anyio
async def test_health(client):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View file

@ -0,0 +1,28 @@
import pytest
from ..utils import domain_validation
@pytest.mark.parametrize(
"domain,allowed,forbidden,is_allowed",
[
("localhost:8000", {"*"}, None, True),
("wrong.com", {"example.com"}, None, False),
("sub.example.com", {"example.com"}, None, False),
("sub.example.com", {"*.example.com"}, None, True),
("sub.sub.example.com", {"*.example.com"}, None, True),
("example.com", {"*.example.com"}, None, False),
("sub.example.com", {"*.sub.example.com"}, None, False),
("sub.sub.example.com", {"*.sub.example.com"}, None, True),
("example.com", {"python.org", "example.com"}, None, True),
("any.com", {"python.org", "example.com", "*"}, None, True),
("example.com", {"*.example.com"}, None, False),
("sub.example.com", {"*"}, {"*.example.com"}, False),
("sub.example.com", {"*.example.com"}, {"*.banned.com"}, True),
("sub.example.com", {"*.example.com"}, {"x.example.com"}, True),
("non-eu-sub.saleor.cloud", {"*.saleor.cloud"}, {"*.eu.saleor.cloud"}, True),
("sub.eu.saleor.cloud", {"*.saleor.cloud"}, {"*.eu.saleor.cloud"}, False),
],
)
def test_domain_validation(domain, allowed, forbidden, is_allowed):
assert domain_validation(domain, allowed, forbidden) is is_allowed

View file

@ -0,0 +1,48 @@
import fnmatch
import urllib.parse
from fastapi import Request
from pydantic import BaseModel
from starlette.datastructures import URL
HttpHeaders = list[tuple[str, str]]
def domain_validation(
domain_name: str, allowed: set[str], forbidden: set[str] | None = None
):
forbidden = set() if forbidden is None else forbidden
for origin in allowed:
if fnmatch.fnmatchcase(domain_name, origin):
for disallowed in forbidden:
if fnmatch.fnmatchcase(domain_name, disallowed):
return False
return True
return False
def to_camel(snake_str: str) -> str:
components = snake_str.split("_")
return components[0] + "".join(x.capitalize() if x else "_" for x in components[1:])
class JsonBaseModel(BaseModel):
class Config:
alias_generator = to_camel
allow_population_by_field_name = True
def parse_headers(headers_list: HttpHeaders) -> dict[str, str]:
headers: dict[str, str] = {}
for key, val in headers_list:
headers[key] = val
return headers
def get_base_url(request: Request) -> URL:
base_url, headers = request.base_url, request.headers
scheme = headers.get("x-forwarded-proto", base_url.scheme).split(",")[0].strip()
if forwarded_host := headers.get("x-forwarded-host"):
parts = urllib.parse.urlsplit(f"//{forwarded_host}")
base_url = base_url.replace(hostname=parts.hostname, port=parts.port)
return base_url.replace(scheme=scheme)

1929
apps/monitoring/backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
[tool.poetry]
name = "saleor-app-monitoring"
version = "0.1.0"
description = "Saleor Monitoring app"
authors = ["Przemysław Łada <przemyslaw.lada@saleor.io>"]
[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.92.0"
uvicorn = {extras = ["standard"], version = "^0.20.0"}
python-dotenv = "^0.21.0"
python-json-logger = "^2.0.4"
datadog-api-client = {extras = ["async"], version = "^2.8.0"}
ua-parser = "^0.16.1"
ariadne = "^0.17.1"
httpx = "^0.23.3"
pyjwt = {extras = ["crypto"], version = "^2.6.0"}
[tool.poetry.group.dev.dependencies]
black = {extras = ["d"], version = "^22.12.0"}
pytest = "^7.2.0"
pre-commit = "^2.21.0"
ruff = "^0.0.215"
mypy = "^0.991"
anyio = "^3.6.2"
pytest-recording = "^0.12.2"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
target-version = "py310"
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I001", # isort
"C", # flake8-comprehensions
"B", # flake8-bugbear
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # too complex
]
fix = true
fixable = ["I001"]
src = ["monitoring"]
[tool.ruff.isort]
known-first-party = ["monitoring"]
[tool.mypy]
allow_redefinition = true
show_error_codes = true
check_untyped_defs = true

View file

@ -0,0 +1,16 @@
version: "3.8"
services:
api:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
stdin_open: true
tty: true
ports:
- "5001:80"
environment:
- DEBUG=True
volumes:
- ./backend/monitoring/:/app/monitoring

5
apps/monitoring/next-env.d.ts vendored Normal file
View 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.

View file

@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
rewrites() {
/**
* For dev/preview Next.js can work as a proxy and redirect unknown paths to provided backend address
*
* In production, when env is not provided, frontend will call its relative path and reverse proxy will do the rest
*/
const backendPath = process.env.MONITORING_APP_API_URL ?? "";
return {
fallback: [
{
source: "/:path*",
destination: `${backendPath}/:path*`,
},
],
};
},
};

View file

@ -0,0 +1,62 @@
{
"name": "saleor-app-monitoring",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"build": "pnpm generate && next build",
"start": "next start",
"lint": "next lint",
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.10"
},
"dependencies": {
"react-hook-form": "^7.42.1",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.27.1",
"@saleor/macaw-ui": "^0.7.2",
"@urql/exchange-auth": "^1.0.0",
"@vitejs/plugin-react": "^3.0.1",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"jsdom": "^20.0.3",
"next": "13.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"urql": "^3.0.3",
"vite": "^4.0.4",
"vitest": "^0.27.1",
"clsx": "^1.2.1",
"@saleor/apps-shared": "workspace:*"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.3",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-urql": "^3.7.0",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.1",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/node": "^18.11.18",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"eslint": "8.31.0",
"eslint-config-next": "13.1.2",
"eslint-config-prettier": "^8.6.0",
"prettier": "^2.8.2",
"typescript": "4.9.4",
"eslint-config-saleor": "workspace:*"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800.5 203.19" style="enable-background:new 0 0 800.5 203.19;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#632CA6;}
</style>
<g>
<g>
<path class="st0" d="M260.87,144.65h-37.4v-86.1h37.4c26.94,0,40.43,13.57,40.43,40.7C301.29,129.51,287.81,144.65,260.87,144.65z
M239.45,130.79h19c17.9,0,26.84-10.51,26.84-31.55c0-17.91-8.95-26.87-26.84-26.87h-19L239.45,130.79L239.45,130.79z"/>
<polygon class="st0" points="318.04,144.65 301.62,144.65 338.25,58.55 355.44,58.55 392.85,144.65 375.66,144.65 364.8,121.17
337.17,121.17 342.66,107.32 360.58,107.32 346.46,74.98 "/>
<polygon class="st0" points="383.82,58.55 449.28,58.55 449.28,72.39 424.55,72.39 424.55,144.65 408.57,144.65 408.57,72.39
383.82,72.39 "/>
<polygon class="st0" points="457.5,144.65 441.08,144.65 477.71,58.55 494.9,58.55 532.31,144.65 515.1,144.65 504.24,121.17
476.61,121.17 482.1,107.32 500.02,107.32 485.91,74.98 "/>
<path class="st0" d="M580.32,144.65h-37.4v-86.1h37.4c26.96,0,40.43,13.57,40.43,40.7C620.75,129.51,607.28,144.65,580.32,144.65z
M558.91,130.79h19c17.89,0,26.86-10.51,26.86-31.55c0-17.91-8.96-26.87-26.86-26.87h-19V130.79z"/>
<path class="st0" d="M631.58,101.72c0-29.2,14.45-43.79,43.33-43.79c28.44,0,42.64,14.59,42.64,43.79
c0,29.03-14.21,43.55-42.64,43.55C647.31,145.27,632.87,130.75,631.58,101.72z M674.91,131.39c17.36,0,26.05-10.01,26.05-30.05
c0-19.72-8.69-29.59-26.05-29.59c-17.82,0-26.73,9.87-26.73,29.59C648.18,121.38,657.09,131.39,674.91,131.39z"/>
<path class="st0" d="M784.26,109.81v20.16c-3.69,0.96-6.99,1.44-9.9,1.44c-19.55,0-29.31-10.34-29.31-31.01
c0-19.09,10.36-28.62,31.07-28.62c8.65,0,16.69,1.61,24.13,4.82V62.14c-7.44-2.8-15.89-4.21-25.34-4.21
c-30.97,0-46.46,14.15-46.46,42.47c0,29.9,15.22,44.87,45.67,44.87c10.47,0,19.17-1.52,26.13-4.58V95.64h-25.82l-5.4,14.16
L784.26,109.81L784.26,109.81z"/>
</g>
<g>
<g>
<path class="st0" d="M158.87,144.16L142,133.04l-14.07,23.5l-16.36-4.78l-14.41,21.99l0.74,6.92l78.33-14.43l-4.55-48.94
L158.87,144.16z M85.82,123.07l12.57-1.73c2.03,0.91,3.45,1.26,5.89,1.88c3.8,0.99,8.19,1.94,14.7-1.34
c1.51-0.75,4.67-3.64,5.94-5.28l51.49-9.34l5.25,63.57l-88.21,15.9L85.82,123.07z M181.46,100.16l-5.08,0.97L166.62,0.25
L0.25,19.54l20.5,166.33l19.47-2.83c-1.55-2.22-3.98-4.91-8.11-8.35c-5.74-4.76-3.71-12.86-0.32-17.97
c4.47-8.63,27.54-19.61,26.23-33.41c-0.47-5.02-1.27-11.55-5.93-16.03c-0.17,1.86,0.14,3.65,0.14,3.65s-1.91-2.44-2.87-5.77
c-0.95-1.28-1.69-1.68-2.7-3.39c-0.72,1.97-0.62,4.26-0.62,4.26s-1.56-3.7-1.82-6.82c-0.93,1.4-1.16,4.05-1.16,4.05
s-2.03-5.83-1.57-8.97c-0.93-2.73-3.68-8.15-2.9-20.47c5.08,3.56,16.26,2.71,20.61-3.71c1.45-2.13,2.44-7.93-0.72-19.36
c-2.03-7.33-7.05-18.25-9.01-22.4l-0.23,0.17c1.03,3.34,3.16,10.33,3.98,13.73c2.47,10.29,3.13,13.87,1.97,18.61
c-0.99,4.12-3.35,6.82-9.35,9.84c-6,3.03-13.96-4.34-14.47-4.74c-5.83-4.64-10.34-12.22-10.84-15.9
c-0.52-4.03,2.32-6.45,3.76-9.74c-2.05,0.59-4.34,1.63-4.34,1.63s2.73-2.83,6.1-5.27c1.4-0.92,2.21-1.51,3.68-2.73
c-2.13-0.03-3.86,0.02-3.86,0.02s3.55-1.92,7.23-3.31c-2.69-0.12-5.27-0.02-5.27-0.02S35.75,27.1,42,24.5
c4.3-1.76,8.5-1.24,10.86,2.17c3.1,4.47,6.35,6.9,13.25,8.41c4.24-1.88,5.52-2.84,10.84-4.29c4.68-5.15,8.36-5.82,8.36-5.82
s-1.82,1.67-2.31,4.3c2.66-2.09,5.57-3.84,5.57-3.84s-1.13,1.39-2.18,3.6l0.24,0.36c3.1-1.86,6.74-3.32,6.74-3.32
s-1.04,1.32-2.26,3.02c2.34-0.02,7.08,0.1,8.91,0.31c10.86,0.24,13.11-11.6,17.28-13.08c5.22-1.86,7.55-2.99,16.44,5.74
c7.63,7.5,13.59,20.91,10.63,23.92c-2.48,2.49-7.38-0.97-12.8-7.74c-2.87-3.58-5.03-7.81-6.05-13.19
c-0.86-4.54-4.19-7.17-4.19-7.17s1.93,4.31,1.93,8.11c0,2.08,0.26,9.84,3.59,14.19c-0.33,0.64-0.48,3.15-0.85,3.63
c-3.87-4.68-12.19-8.03-13.54-9.02c4.59,3.76,15.14,12.4,19.19,20.68c3.83,7.83,1.57,15.01,3.51,16.87
c0.55,0.53,8.24,10.11,9.72,14.93c2.58,8.39,0.15,17.21-3.22,22.68l-9.43,1.47c-1.38-0.38-2.31-0.58-3.55-1.29
c0.68-1.21,2.04-4.22,2.05-4.84l-0.53-0.93c-2.94,4.16-7.85,8.2-11.94,10.52c-5.35,3.03-11.51,2.56-15.52,1.32
c-11.39-3.51-22.16-11.21-24.75-13.23c0,0-0.08,1.61,0.41,1.98c2.87,3.24,9.45,9.1,15.81,13.18l-13.55,1.49l6.41,49.89
c-2.84,0.41-3.28,0.61-6.39,1.05c-2.74-9.68-7.98-16.01-13.71-19.69c-5.05-3.25-12.02-3.98-18.7-2.66l-0.43,0.5
c4.64-0.48,10.12,0.19,15.74,3.75c5.52,3.49,9.97,12.51,11.61,17.94c2.1,6.94,3.55,14.36-2.1,22.23
c-4.02,5.59-15.74,8.68-25.22,2c2.53,4.07,5.95,7.4,10.55,8.02c6.84,0.93,13.33-0.26,17.79-4.84c3.81-3.92,5.84-12.12,5.3-20.75
l6.03-0.87l2.18,15.49l99.88-12.03L181.46,100.16z M120.69,58.08c-0.28,0.64-0.72,1.05-0.06,3.12l0.04,0.12l0.1,0.27l0.27,0.62
c1.19,2.42,2.49,4.71,4.66,5.88c0.56-0.09,1.15-0.16,1.75-0.19c2.04-0.09,3.33,0.23,4.15,0.68c0.07-0.41,0.09-1,0.04-1.88
c-0.16-3.07,0.61-8.29-5.29-11.04c-2.23-1.03-5.35-0.72-6.39,0.58c0.19,0.02,0.36,0.06,0.49,0.11
C122.04,56.89,120.98,57.43,120.69,58.08 M137.23,86.73c-0.77-0.43-4.39-0.26-6.93,0.04c-4.84,0.57-10.07,2.25-11.22,3.14
c-2.08,1.61-1.14,4.42,0.4,5.57c4.32,3.22,8.1,5.39,12.09,4.86c2.45-0.32,4.61-4.2,6.14-7.73
C138.77,90.19,138.77,87.58,137.23,86.73 M94.36,61.88c1.37-1.3-6.8-3-13.14,1.32c-4.67,3.19-4.82,10.03-0.35,13.9
c0.45,0.38,0.82,0.66,1.16,0.88c1.31-0.62,2.8-1.24,4.51-1.79c2.9-0.94,5.3-1.43,7.28-1.68c0.95-1.06,2.05-2.92,1.77-6.29
C95.22,63.63,91.75,64.36,94.36,61.88"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800.5 196.2" style="enable-background:new 0 0 800.5 196.2;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<g>
<g>
<path class="st0" d="M167.2,98.4l-54.9,10c-1.4,1.8-4.7,4.8-6.3,5.6c-6.9,3.5-11.6,2.5-15.7,1.4c-2.6-0.7-4.1-1-6.3-2l-13.4,1.8
l8.1,67.9l94.1-17L167.2,98.4z M83.4,176.7l-0.8-7.4L98,145.9l17.4,5.1l15-25.1l18,11.9l13.7-28.7l4.9,52.2L83.4,176.7z M79.5,110
c-6.8-4.4-13.8-10.6-16.9-14.1c-0.5-0.4-0.4-2.1-0.4-2.1c2.8,2.2,14.3,10.4,26.4,14.1c4.3,1.3,10.9,1.8,16.6-1.4
c4.4-2.5,9.6-6.8,12.7-11.2l0.6,1c0,0.7-1.5,3.9-2.2,5.2c1.3,0.8,2.3,1,3.8,1.4l10.1-1.6c3.6-5.8,6.2-15.2,3.4-24.2
c-1.6-5.1-9.8-15.4-10.4-15.9c-2.1-2,0.3-9.6-3.7-18c-4.3-8.8-15.6-18-20.5-22.1c1.4,1,10.3,4.6,14.4,9.6c0.4-0.5,0.6-3.2,0.9-3.9
c-3.6-4.6-3.8-12.9-3.8-15.1c0-4.1-2.1-8.7-2.1-8.7s3.6,2.8,4.5,7.7c1.1,5.7,3.4,10.3,6.5,14.1c5.8,7.2,11,10.9,13.7,8.2
c3.2-3.2-3.2-17.5-11.3-25.5c-9.5-9.3-12-8.1-17.5-6.1c-4.4,1.5-6.8,14.2-18.4,13.9c-2-0.2-7-0.4-9.5-0.3c1.3-1.8,2.4-3.2,2.4-3.2
s-3.9,1.6-7.2,3.6l-0.3-0.4c1.1-2.4,2.3-3.8,2.3-3.8s-3.1,1.9-5.9,4.1c0.5-2.8,2.5-4.6,2.5-4.6s-3.9,0.7-8.9,6.2
c-5.7,1.5-7,2.6-11.6,4.6c-7.4-1.6-10.8-4.2-14.1-9c-2.5-3.6-7-4.2-11.6-2.3c-6.7,2.8-15.1,6.5-15.1,6.5s2.8-0.1,5.6,0
c-3.9,1.5-7.7,3.5-7.7,3.5s1.8-0.1,4.1,0c-1.6,1.3-2.4,1.9-3.9,2.9c-3.6,2.6-6.5,5.6-6.5,5.6s2.4-1.1,4.6-1.7
c-1.5,3.5-4.6,6.1-4,10.4c0.5,3.9,5.3,12,11.6,17c0.5,0.4,9,8.3,15.4,5.1c6.4-3.2,8.9-6.1,10-10.5c1.2-5.1,0.5-8.9-2.1-19.9
c-0.9-3.6-3.1-11.1-4.2-14.6l0.2-0.2c2.1,4.4,7.4,16.1,9.6,23.9c3.4,12.2,2.3,18.4,0.8,20.7c-4.7,6.8-16.6,7.8-22,4
c-0.8,13.1,2.1,18.9,3.1,21.8c-0.5,3.3,1.7,9.6,1.7,9.6s0.2-2.8,1.2-4.3c0.3,3.3,1.9,7.3,1.9,7.3s-0.1-2.4,0.7-4.5
c1.1,1.8,1.9,2.2,2.9,3.6c1,3.6,3,6.2,3,6.2s-0.3-1.9-0.2-3.9c5,4.8,5.8,11.8,6.3,17.1c1.4,14.7-23.2,26.4-28,35.6
c-3.6,5.4-5.8,14.1,0.3,19.2c14.8,12.3,9.1,15.7,16.5,21.1c10.2,7.4,22.9,4.1,27.2-1.9c6-8.4,4.5-16.3,2.2-23.7
c-1.8-5.8-6.5-15.4-12.4-19.1c-6-3.8-11.9-4.5-16.8-4l0.5-0.5c7.1-1.4,14.6-0.6,20,2.8c6.1,3.9,11.7,10.7,14.6,21
c3.3-0.5,3.8-0.7,6.8-1.1L65,111.6L79.5,110z M113.8,43.3c6.3,2.9,5.5,8.5,5.6,11.8c0.1,0.9,0,1.6-0.1,2c-0.9-0.5-2.2-0.8-4.4-0.7
c-0.6,0-1.3,0.1-1.9,0.2c-2.3-1.2-3.7-3.7-5-6.3c-0.1-0.2-0.2-0.5-0.3-0.7c0-0.1-0.1-0.2-0.1-0.3c0,0,0-0.1,0-0.1
c-0.7-2.2-0.2-2.7,0.1-3.3s1.4-1.3-0.2-1.8c-0.1,0-0.3-0.1-0.5-0.1C108.1,42.6,111.4,42.2,113.8,43.3z M106,79.9
c1.2-0.9,6.8-2.7,12-3.4c2.7-0.3,6.6-0.5,7.4,0c1.6,0.9,1.6,3.7,0.5,6.3c-1.6,3.8-3.9,7.9-6.6,8.2c-4.3,0.6-8.3-1.7-12.9-5.2
C104.8,84.6,103.8,81.6,106,79.9z M65.6,51.4c6.8-4.6,15.5-2.8,14-1.4c-2.8,2.7,0.9,1.9,1.3,6.8c0.3,3.6-0.9,5.6-1.9,6.7
c-2.1,0.3-4.7,0.8-7.8,1.8c-1.8,0.6-3.4,1.2-4.8,1.9c-0.4-0.2-0.8-0.5-1.2-0.9C60.5,62.1,60.7,54.8,65.6,51.4z"/>
</g>
<g>
<path class="st1" d="M249.2,141.8H211v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C290.5,126.3,276.8,141.8,249.2,141.8z M227.4,127.6
h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.1-27.4-27.4-27.4h-19.4V127.6L227.4,127.6z"/>
<polygon class="st1" points="307.6,141.8 290.9,141.8 328.3,53.8 345.9,53.8 384.1,141.8 366.5,141.8 355.4,117.8 327.2,117.8
332.8,103.7 351.1,103.7 336.7,70.6 "/>
<polygon class="st1" points="374.8,53.8 441.7,53.8 441.7,68 416.5,68 416.5,141.8 400.1,141.8 400.1,68 374.8,68 "/>
<polygon class="st1" points="450.1,141.8 433.3,141.8 470.8,53.8 488.3,53.8 526.5,141.8 509,141.8 497.9,117.8 469.6,117.8
475.2,103.7 493.5,103.7 479.1,70.6 "/>
<path class="st1" d="M575.6,141.8h-38.2v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C616.9,126.3,603.1,141.8,575.6,141.8z M553.7,127.6
h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.2-27.4-27.4-27.4h-19.4V127.6z"/>
<path class="st1" d="M628,97.9c0-29.8,14.8-44.7,44.3-44.7c29,0,43.6,14.9,43.6,44.7c0,29.7-14.5,44.5-43.6,44.5
C644,142.4,629.3,127.6,628,97.9z M672.2,128.2c17.7,0,26.6-10.2,26.6-30.7c0-20.2-8.9-30.2-26.6-30.2
c-18.2,0-27.3,10.1-27.3,30.2C644.9,118,654,128.2,672.2,128.2z"/>
<path class="st1" d="M783.9,106.2v20.6c-3.8,1-7.1,1.5-10.1,1.5c-20,0-30-10.6-30-31.7c0-19.5,10.6-29.2,31.7-29.2
c8.8,0,17,1.6,24.7,4.9V57.5c-7.6-2.9-16.2-4.3-25.9-4.3c-31.6,0-47.5,14.4-47.5,43.4c0,30.6,15.5,45.8,46.7,45.8
c10.7,0,19.6-1.6,26.7-4.7v-46h-26.4l-5.5,14.5H783.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1,18 @@
<svg width="409" height="137" viewBox="0 0 409 137" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M167.17 95.75C164.97 97.06 159.17 97.63 156.32 97.63C148.33 97.63 143.52 93.47 143.52 86.38V50.32L138.79 49.83V39.48H158.24V82.46C158.24 84.91 159.67 85.97 162.52 85.97H165.54L167.17 95.75Z" fill="#002E42"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M242.2 65.25H237.39C232.82 65.25 231.35 66.31 231.35 70.06C231.35 73.81 232.98 75.36 237.39 75.36C241.87 75.36 243.26 73.73 243.26 69.98C243.26 67.69 242.85 66.23 242.2 65.25ZM256.15 65.25C257.7 66.8 258.11 68.76 258.11 70.71C258.11 79.27 251.83 83.76 237.31 83.76C235.27 83.76 231.84 83.52 231.76 83.52C230.93 83.52 230.75 84 230.69 84.19C230.12 86.13 231.68 86.42 235.68 87.12L247.17 88.82C256.72 90.29 259.08 94.77 259.08 100.41C259.08 107.75 255.09 113.13 236.9 113.13C230.54 113.13 223.44 112.4 217.49 111.42V101.47C224.66 102.04 230.36 102.53 237.05 102.53C242.51 102.53 244.57 102.36 244.57 100.33C244.65 98.7 242.72 98.22 238.45 97.51L227.92 95.85C221.07 94.62 219.03 90.71 219.19 86.96C219.27 84.68 220.41 82.47 222.37 81.09C218.05 78.81 216.09 75.06 216.09 69.75C216.09 59.15 223.84 56.05 237.46 56.05H264.03V65.27H256.15V65.25Z" fill="#002E42"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M349.8 56.29H342.8H337.89V59.54V59.69V65.74V68.99H342.8V86.47V96.83H357.57V86.47V56.29H349.8Z" fill="#002E42"/>
<path d="M348.42 36.24C353.01 36.24 356.23 39.38 356.23 43.8C356.23 48.13 353 51.27 348.42 51.27C343.92 51.27 340.69 48.13 340.69 43.8C340.69 39.38 343.92 36.24 348.42 36.24Z" fill="#002E42"/>
<path d="M324.6 81.84C329.19 81.84 332.41 84.98 332.41 89.4C332.41 93.73 329.18 96.87 324.6 96.87C320.1 96.87 316.87 93.73 316.87 89.4C316.87 84.98 320.1 81.84 324.6 81.84Z" fill="#002E42"/>
<path d="M190.07 54.89C177.3 54.89 168.14 63.8 168.14 76.34C168.14 88.63 177.3 97.55 190.07 97.55C203.08 97.55 212.24 88.63 212.24 76.34C212.24 63.8 203.08 54.89 190.07 54.89ZM190.14 85.16C184.79 85.16 180.95 81.42 180.95 76.27C180.95 71.02 184.79 67.28 190.14 67.28C195.59 67.28 199.43 71.02 199.43 76.27C199.43 81.42 195.59 85.16 190.14 85.16Z" fill="#002E42"/>
<path d="M386 55.15C373.23 55.15 364.07 64.06 364.07 76.6C364.07 88.89 373.23 97.81 386 97.81C399.01 97.81 408.17 88.89 408.17 76.6C408.18 64.06 399.02 55.15 386 55.15ZM386.07 85.42C380.72 85.42 376.88 81.68 376.88 76.53C376.88 71.28 380.72 67.54 386.07 67.54C391.52 67.54 395.36 71.28 395.36 76.53C395.36 81.68 391.53 85.42 386.07 85.42Z" fill="#002E42"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M306.48 96.49H267.49V86.45L289.43 65.9H278.75H278.1H270.15V55.95H306.07V65.9L284.29 86.45L295.96 86.53V81.56H306.48V96.49Z" fill="#002E42"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 102.13L58.97 136.18L117.94 102.13V34.04L58.97 0L2.95 32.35C1.82 33 1.12 34.22 1.12 35.52C1.12 36.82 1.82 38.04 2.95 38.69L56.42 69.56V96.53L0 63.96V102.13ZM58.96 130.29L5.09 99.19V72.78L29.144 86.6631L29.14 86.67L58.77 104L89.86 86.06V50.17L58.78 32.23L30.3702 48.6309L7.64 35.51L58.97 5.88L112.83 36.99V99.19L58.96 130.29ZM35.4647 51.5717L58.97 65.14L82.2835 51.6749L58.78 38.11L35.4647 51.5717ZM84.77 56.1216L61.52 69.55V96.5348L84.77 83.12V56.1216Z" fill="#053447"/>
</g>
<defs>
<clipPath id="clip0">
<path d="M0 0H408.18V136.18H0V0Z" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 819 158.97"><defs><style>.cls-1{fill:#00ac69;}.cls-2{fill:#1ce783;}.cls-3{fill:#1d252c;}</style></defs><polygon class="cls-1" points="111.19 55.03 111.19 103.94 68.84 128.4 68.84 158.97 137.68 119.23 137.68 39.74 111.19 55.03"/><polygon class="cls-2" points="68.84 30.58 111.19 55.03 137.68 39.74 137.68 39.74 68.84 0 0 39.74 0 39.74 26.48 55.03 68.84 30.58"/><polygon class="cls-3" points="42.36 94.78 42.36 143.69 68.84 158.97 68.84 79.49 0 39.74 0 70.32 42.36 94.78"/><path class="cls-3" d="M242.17,50.14c-14.82,0-21.84,9.36-21.84,9.36h-.78L218,51.7H200.05v79.56h19.5v-46c0-10.14,7-17.16,17.16-17.16s17.16,7,17.16,17.16v46h19.5V83.68C273.37,63.4,260.11,50.14,242.17,50.14Z" transform="translate(-10 -10.51)"/><polygon class="cls-3" points="442.47 93.45 441.35 93.45 428.09 41.19 408.25 41.19 394.99 93.45 393.88 93.45 380.61 41.19 360.33 41.19 380.61 120.75 404.36 120.75 417.61 69.27 418.73 69.27 431.99 120.75 455.73 120.75 476.01 41.19 455.73 41.19 442.47 93.45"/><path class="cls-3" d="M545.72,58.72h-.78l-1.56-7H527v79.55h19.5v-46c0-10.14,4.68-14.82,14.82-14.82h10V51.71h-11.6A17.56,17.56,0,0,0,545.72,58.72Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M614.47,50.14c-23.39,0-40.55,17.16-40.55,41.34s16.19,41.34,40.55,41.34c19.73,0,31.61-11.61,36.56-20.15l-17.9-6.38c-1.77,3.24-8.91,9.47-18.66,9.47-11.37,0-19.49-7.12-21.05-18h59.27a35.38,35.38,0,0,0,.78-7.8C653.47,67.3,636.31,50.14,614.47,50.14ZM593.42,84.46c2.34-10.14,9.36-17.94,21.05-17.94,10.93,0,17.94,7.8,19.5,17.94Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M326.4,50.14c-23.4,0-40.56,17.16-40.56,41.34S302,132.82,326.4,132.82c19.73,0,31.6-11.61,36.55-20.15l-17.9-6.38c-1.77,3.24-8.9,9.47-18.65,9.47-11.37,0-19.5-7.12-21.06-18h59.28a35.38,35.38,0,0,0,.78-7.8C365.4,67.3,348.24,50.14,326.4,50.14ZM305.34,84.46c2.34-10.14,9.36-17.94,21.06-17.94,10.92,0,17.94,7.8,19.5,17.94Z" transform="translate(-10 -10.51)"/><rect class="cls-3" x="692.14" y="9.78" width="19.5" height="19.5"/><path class="cls-3" d="M775.45,114.88c-11.7,0-21.06-9.36-21.06-23.4s9.36-23.4,21.06-23.4,16.38,7.8,17.94,12.48l17.66-6.28c-4.28-11.11-14.78-24.14-35.6-24.14-23.4,0-40.56,17.16-40.56,41.34s17.16,41.34,40.56,41.34c21,0,31.5-13.24,35.7-24.88l-17.76-6.32C791.83,107.08,787.15,114.88,775.45,114.88Z" transform="translate(-10 -10.51)"/><polygon class="cls-3" points="645.63 27.11 656.7 27.11 656.7 120.75 676.2 120.75 676.2 9.78 645.63 9.78 645.63 27.11"/><rect class="cls-3" x="692.14" y="41.19" width="19.5" height="79.56"/><path class="cls-3" d="M821.59,116a7.52,7.52,0,1,0,7.41,7.52A7.28,7.28,0,0,0,821.59,116Zm0,13.89a6.37,6.37,0,1,1,6.26-6.37A6.12,6.12,0,0,1,821.59,129.85Z" transform="translate(-10 -10.51)"/><path class="cls-3" d="M824.82,122.13a2.64,2.64,0,0,0-2.82-2.62h-3.34v7.84h1.15v-2.72h1.05l2.71,2.72H825l-2.71-2.72A2.53,2.53,0,0,0,824.82,122.13Zm-5,1.35v-2.82H822a1.5,1.5,0,0,1,1.68,1.47c0,.83-.53,1.35-1.68,1.35Z" transform="translate(-10 -10.51)"/></svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,17 @@
import { DatadogSite } from "../generated/graphql";
export const API_KEYS_LINKS: { [key in DatadogSite]: string } = {
[DatadogSite.Us1]: "https://app.datadoghq.com/organization-settings/api-keys",
[DatadogSite.Us3]: "https://us3.datadoghq.com/organization-settings/api-keys",
[DatadogSite.Us5]: "https://us5.datadoghq.com/organization-settings/api-keys",
[DatadogSite.Eu1]: "https://app.datadoghq.eu/organization-settings/api-keys",
[DatadogSite.Us1Fed]: "https://app.ddog-gov.com/organization-settings/api-keys",
};
export const DATADOG_SITES_LINKS: { [key in DatadogSite]: string } = {
[DatadogSite.Us1]: "datadoghq.com",
[DatadogSite.Us3]: "us3.datadoghq.com",
[DatadogSite.Us5]: "us5.datadoghq.com",
[DatadogSite.Eu1]: "datadoghq.eu",
[DatadogSite.Us1Fed]: "ddog-gov.com",
};

View file

@ -0,0 +1,18 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createClient } from "./lib/create-graphq-client";
function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const client = createClient(
`/graphql`,
async () => Promise.resolve({ token: appBridgeState?.token! }),
() => appBridgeState?.saleorApiUrl!
);
return <Provider value={client} {...props} />;
}
export default GraphQLProvider;

View file

@ -0,0 +1,53 @@
import { 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"],
getSaleorApiUrl: () => string
) =>
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,
"Saleor-Api-Url": getSaleorApiUrl(),
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
});

View file

@ -0,0 +1,7 @@
export function isInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}

View file

@ -0,0 +1,19 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -0,0 +1,33 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
/**
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
* TODO Fix me when Macaw 2.0 is shipped
*/
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
}
}, [appBridgeState?.theme, setTheme, themeType]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -0,0 +1,30 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
export const useDashboardNotifications = () => {
const { appBridge } = useAppBridge();
const showSuccessNotification = (title: string, text: string) => {
appBridge?.dispatch(
actions.Notification({
title: title,
text: text,
status: "success",
})
);
};
const showErrorNotification = (title: string, text: string) => {
appBridge?.dispatch(
actions.Notification({
title: title,
text: text,
status: "error",
})
);
};
return {
showSuccessNotification,
showErrorNotification,
};
};

View file

@ -0,0 +1,92 @@
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
import GraphQLProvider from "../graphql-provider";
const themeOverrides: Partial<Theme> = {
/**
* You can override MacawUI theme here
*/
};
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
const appBridgeInstance =
typeof window !== "undefined"
? new AppBridge({
initialTheme: "light",
})
: undefined;
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
>;
function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider>
<ThemeProvider overrides={themeOverrides} ssr palettes={palettes}>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</GraphQLProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default NextApp;

View file

@ -0,0 +1,116 @@
import { NextPage } from "next";
import { AppColumnsLayout } from "../../ui/app-columns-layout";
import React, { useEffect } from "react";
import { IntegrationsList } from "../../ui/providers-list";
import { NoProvidersConfigured } from "../../ui/no-providers-configured";
import { AppMainBar } from "../../ui/app-main-bar";
import { useRouter } from "next/router";
import { DatadogConfig } from "../../ui/datadog/datadog-config";
import { DatadogSite, useConfigQuery } from "../../../generated/graphql";
import { LinearProgress, Link, Typography } from "@material-ui/core";
import { Section } from "../../ui/sections";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Done, Error } from "@material-ui/icons";
import { makeStyles } from "@saleor/macaw-ui";
import { DATADOG_SITES_LINKS } from "../../datadog-urls";
const useStyles = makeStyles((theme) => ({
wrapper: {},
}));
const useActiveProvider = () => {
const router = useRouter();
const selectedProvider = router.query?.path && router.query.path[0];
return selectedProvider ?? null;
};
const Content = () => {
const [configuration, fetchConfiguration] = useConfigQuery();
const { appBridge } = useAppBridge();
const datadogCredentials = configuration.data?.integrations.datadog?.credentials;
const datadogError = configuration.data?.integrations.datadog?.error;
useEffect(() => {
fetchConfiguration();
}, [fetchConfiguration]);
const selectedProvider = useActiveProvider();
if (configuration.fetching && !configuration.data) {
return <LinearProgress />;
}
if (selectedProvider === "datadog") {
return <DatadogConfig />;
}
if (!configuration.data?.integrations.datadog) {
return <NoProvidersConfigured />;
}
// when configured and everything is fine
if (datadogCredentials && !datadogError) {
const site = configuration.data?.integrations.datadog?.credentials.site ?? DatadogSite.Us1;
return (
<Section>
<Typography paragraph variant="h3">
<Done style={{ verticalAlign: "middle", marginRight: 10 }} />
App configured
</Typography>
<Typography paragraph>
Visit{" "}
<Link
href="https://app.datadoghq.com/"
onClick={(e) => {
e.preventDefault();
appBridge?.dispatch(
actions.Redirect({
to: DATADOG_SITES_LINKS[site],
newContext: true,
})
);
}}
>
Datadog
</Link>{" "}
to access your logs
</Typography>
</Section>
);
}
if (datadogError) {
return (
<Section>
<Typography paragraph variant="h3">
<Error style={{ verticalAlign: "middle", marginRight: 10 }} />
Configuration Error
</Typography>
<Typography>{datadogError}</Typography>
</Section>
);
}
return null;
};
const ConfigurationPage: NextPage = () => {
const styles = useStyles();
const selectedProvider = useActiveProvider();
return (
<div className={styles.wrapper}>
<AppMainBar />
<AppColumnsLayout>
<IntegrationsList activeProvider={selectedProvider} />
<Content />
</AppColumnsLayout>
</div>
);
};
export default ConfigurationPage;

View file

@ -0,0 +1,34 @@
import { NextPage } from "next";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { MouseEventHandler, useEffect, useState } from "react";
import { LinearProgress, Link } from "@material-ui/core";
import { isInIframe } from "../lib/is-in-iframe";
import { useRouter } from "next/router";
/**
* This is page publicly accessible from your app.
* You should probably remove it.
*/
const IndexPage: NextPage = () => {
const { appBridgeState, appBridge } = useAppBridge();
const { replace } = useRouter();
useEffect(() => {
if (appBridgeState?.ready) {
replace("/configuration");
}
}, [appBridgeState?.ready, replace]);
if (isInIframe()) {
return <LinearProgress />;
}
return (
<div>
<h1>Saleor Monitoring</h1>
<p>Install App in Saleor to use it</p>
</div>
);
};
export default IndexPage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,27 @@
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import { APL, FileAPL, UpstashAPL, VercelAPL } from "@saleor/app-sdk/APL";
/**
* By default auth data are stored in the `.auth-data.json` (FileAPL).
* For multi-tenant applications and deployments please use UpstashAPL.
*
* To read more about storing auth data, read the
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
*/
export let apl: APL;
switch (process.env.APL) {
case "vercel":
apl = new VercelAPL();
break;
case "upstash":
// Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
apl = new UpstashAPL();
break;
default:
apl = new FileAPL();
}
export const saleorApp = new SaleorApp({
apl,
});

View file

@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {}

View file

@ -0,0 +1,13 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
code {
border-radius: 5px;
display: inline-block;
margin-top: 10px;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}

View file

@ -0,0 +1,21 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
const useStyles = makeStyles({
root: {
display: "grid",
gridTemplateColumns: "280px auto 280px",
alignItems: "start",
gap: 32,
maxWidth: 1180,
margin: "0 auto",
},
});
type Props = PropsWithChildren<{}>;
export function AppColumnsLayout({ children }: Props) {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
}

View file

@ -0,0 +1,28 @@
import { Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles({
appIconContainer: {
background: "rgb(58, 86, 199)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
color: "#fff",
width: 50,
height: 50,
},
});
export function AppIcon() {
const styles = useStyles();
return (
<div className={styles.appIconContainer}>
<div>
<Typography variant="h2">M</Typography>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { MainBar } from "./main-bar";
import { AppIcon } from "./app-icon";
import React from "react";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { GitHub, OfflineBoltOutlined } from "@material-ui/icons";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
const useStyles = makeStyles({
buttonsGrid: { display: "flex", gap: 10 },
});
export const AppMainBar = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const openInNewTab = (url: string) => {
appBridge?.dispatch(
actions.Redirect({
to: url,
newContext: true,
})
);
};
return (
<MainBar
name="Monitoring"
author="By Saleor Commerce"
icon={<AppIcon />}
bottomMargin
rightColumnContent={
<div className={styles.buttonsGrid}>
<Button
variant="secondary"
startIcon={<GitHub />}
onClick={() => {
openInNewTab("https://github.com/saleor/apps");
}}
>
Repository
</Button>
<Button
startIcon={<OfflineBoltOutlined />}
variant="secondary"
onClick={() => {
openInNewTab("https://github.com/saleor/apps/discussions");
}}
>
Request a feature
</Button>
</div>
}
/>
);
};

View file

@ -0,0 +1,293 @@
import { Controller, useForm } from "react-hook-form";
import {
DataDogCredentialsInput,
DatadogSite,
useConfigQuery,
Mutation,
useUpdateCredentialsMutation,
useDeleteDatadogCredentialsMutation,
} from "../../../generated/graphql";
import { Section } from "../sections";
import {
InputLabel,
LinearProgress,
MenuItem,
Select,
TextField,
Typography,
Checkbox,
FormGroup,
FormControlLabel,
Link,
} from "@material-ui/core";
import React, { useEffect, useState } from "react";
import { Button, makeStyles, Backlink, IconButton } from "@saleor/macaw-ui";
import Image from "next/image";
import DatadogLogo from "../../assets/datadog/dd_logo_h_rgb.svg";
import { gql, useMutation } from "urql";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { ArrowBack } from "@material-ui/icons";
import { useRouter } from "next/router";
import { useDashboardNotifications } from "../../lib/use-dashboard-notifications";
import { API_KEYS_LINKS } from "../../datadog-urls";
const useStyles = makeStyles({
form: {
marginTop: 50,
display: "grid",
gridAutoFlow: "row",
gap: 30,
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
headline: {
marginRight: "auto",
marginLeft: 10,
},
});
gql`
query Config {
integrations {
datadog {
error
active
credentials {
apiKeyLast4
site
}
}
}
}
`;
gql`
mutation UpdateCredentials($input: DatadogConfigInput!) {
updateDatadogConfig(input: $input) {
datadog {
error
active
credentials {
apiKeyLast4
site
}
}
errors {
message
field
}
}
}
`;
gql`
mutation DeleteDatadogCredentials {
deleteDatadogConfig {
errors {
field
message
}
datadog {
credentials {
site
apiKeyLast4
}
}
}
}
`;
const buildMaskedKey = (keyLastChars: string) => `************${keyLastChars}`;
const ApiKeyHelperText = ({ site }: { site: DatadogSite }) => {
const url = API_KEYS_LINKS[site];
const { appBridge } = useAppBridge();
return (
<span>
Get one{" "}
<Link
href={url}
onClick={(e) => {
e.preventDefault();
appBridge?.dispatch(
actions.Redirect({
to: url,
newContext: true,
})
);
}}
>
here
</Link>
</span>
);
};
export const DatadogConfig = () => {
const styles = useStyles();
const [queryData, fetchConfig] = useConfigQuery();
const [, mutateCredentials] = useUpdateCredentialsMutation();
const [, deleteCredentials] = useDeleteDatadogCredentialsMutation();
const router = useRouter();
const { showSuccessNotification, showErrorNotification } = useDashboardNotifications();
const { register, handleSubmit, setValue, control, reset, watch } = useForm<
DataDogCredentialsInput & {
active: boolean;
}
>({
defaultValues: {
site: DatadogSite.Us1,
apiKey: "",
active: true,
},
});
const activeSite = watch("site");
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const updateValuesToCurrentConfig = () => {
const datadogConfig = queryData.data?.integrations.datadog;
if (!datadogConfig) {
return;
}
setValue("active", datadogConfig.active);
setValue("apiKey", buildMaskedKey(datadogConfig.credentials.apiKeyLast4));
setValue("site", datadogConfig.credentials.site);
};
useEffect(() => {
const datadogConfig = queryData.data?.integrations.datadog;
if (datadogConfig) {
updateValuesToCurrentConfig();
}
}, [queryData.data, setValue]);
if (queryData.fetching && !queryData.data) {
return <LinearProgress />;
}
return (
<Section>
<div className={styles.header}>
<IconButton
onClick={() => {
router.push("/configuration");
}}
variant="secondary"
>
<ArrowBack />
</IconButton>
<Typography className={styles.headline} variant="h3">
Configuration
</Typography>
<Image width={100} src={DatadogLogo} alt="DataDog" />
</div>
<form
className={styles.form}
onSubmit={handleSubmit((values) => {
return mutateCredentials({
input: {
active: values.active,
credentials: {
apiKey: values.apiKey,
site: values.site,
},
},
}).then((res) => {
const updatedConfig = res.data?.updateDatadogConfig.datadog;
const errors = res.data?.updateDatadogConfig.errors;
if (updatedConfig) {
setValue("active", updatedConfig.active);
setValue("apiKey", buildMaskedKey(updatedConfig.credentials.apiKeyLast4));
setValue("site", updatedConfig.credentials.site);
showSuccessNotification(
"Configuration updated",
"Successfully updated Datadog settings"
);
}
if (errors?.length) {
showErrorNotification("Error configuring Datadog", errors[0].message);
}
});
})}
>
<div>
<Controller
render={({ field }) => {
return (
<FormGroup row>
<FormControlLabel
control={<Checkbox checked={field.value} {...field} />}
label="Enabled"
/>
</FormGroup>
);
}}
name="active"
control={control}
/>
<InputLabel id="datadog-site-label">Datadog Site</InputLabel>
<Select
defaultValue={DatadogSite.Us1}
fullWidth
labelId="datadog-site-label"
{...register("site")}
>
{Object.values(DatadogSite).map((v) => (
<MenuItem value={v} key={v}>
{v}
</MenuItem>
))}
</Select>
</div>
<TextField
fullWidth
variant="standard"
label="Api Key"
defaultValue=""
helperText={<ApiKeyHelperText site={activeSite} />}
{...register("apiKey")}
/>
{queryData.data?.integrations.datadog?.error && (
<Typography color="error">{queryData.data?.integrations.datadog?.error}</Typography>
)}
<Button type="submit" variant="primary" fullWidth>
Save configuration
</Button>
<Button
type="reset"
variant="secondary"
fullWidth
onClick={(e) => {
e.preventDefault();
deleteCredentials({}).then(() => {
fetchConfig();
reset();
showSuccessNotification(
"Configuration updated",
"Successfully deleted Datadog settings"
);
});
}}
>
Delete configuration
</Button>
</form>
</Section>
);
};

View file

@ -0,0 +1,69 @@
import { Paper, PaperProps } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import clsx from "clsx";
import { ReactNode } from "react";
const useStyles = makeStyles((theme) => ({
root: {
height: 96,
padding: "0 32px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${theme.palette.grey.A100} `,
},
leftColumn: {
marginRight: "auto",
},
rightColumn: {},
iconColumn: {
marginRight: 24,
},
appName: { fontSize: 24, margin: 0 },
appAuthor: {
fontSize: 12,
textTransform: "uppercase",
color: theme.palette.text.secondary,
fontWeight: 500,
margin: 0,
},
bottomMargin: {
marginBottom: 32,
},
}));
type Props = {
name: string;
author: string;
rightColumnContent?: ReactNode;
icon?: ReactNode;
bottomMargin?: boolean;
} & PaperProps;
export function MainBar({
name,
author,
rightColumnContent,
className,
icon,
bottomMargin,
}: Props) {
const styles = useStyles();
return (
<Paper
square
elevation={0}
className={clsx(styles.root, className, {
[styles.bottomMargin]: bottomMargin,
})}
>
{icon && <div className={styles.iconColumn}>{icon}</div>}
<div className={styles.leftColumn}>
<h1 className={styles.appName}>{name}</h1>
<h1 className={styles.appAuthor}>{author}</h1>
</div>
<div className={styles.rightColumn}>{rightColumnContent}</div>
</Paper>
);
}

View file

@ -0,0 +1,13 @@
import { Section } from "./sections";
import { Typography } from "@material-ui/core";
export const NoProvidersConfigured = () => (
<Section>
<Typography paragraph variant="h3">
No providers configured
</Typography>
<Typography paragraph>
Chose one of providers on the left and configure it to use the app
</Typography>
</Section>
);

View file

@ -0,0 +1,99 @@
import { makeStyles } from "@saleor/macaw-ui";
import Image from "next/image";
import DatadogLogo from "../assets/datadog/dd_logo_h_rgb.svg";
import NewRelicLogo from "../assets/new-relic/new_relic_logo_horizontal.svg";
import LogzLogo from "../assets/logzio/1584985593-blue-horizontal.svg";
import React from "react";
import { Section } from "./sections";
import { Typography } from "@material-ui/core";
import clsx from "clsx";
import { useRouter } from "next/router";
import { Done, Error } from "@material-ui/icons";
import { useConfigQuery } from "../../generated/graphql";
const useStyles = makeStyles((theme) => {
return {
item: {
cursor: "pointer",
display: "flex",
marginBottom: 20,
padding: "10px",
justifyContent: "space-between",
border: "1px solid transparent",
},
disabledItem: {
filter: "grayscale(1)",
opacity: 0.7,
pointerEvents: "none",
marginBottom: 20,
padding: "10px",
},
selected: {
border: `1px solid ${theme.palette.divider} !important`,
borderRadius: 4,
},
list: {
margin: 0,
padding: 0,
listStyle: "none",
},
};
});
type Props = {
activeProvider: "datadog" | string | null;
};
export const IntegrationsList = ({ activeProvider }: Props) => {
const styles = useStyles();
const router = useRouter();
const [queryData] = useConfigQuery();
const isDatadogConfigured = queryData.data?.integrations.datadog?.credentials;
const isDatadogError = queryData.data?.integrations.datadog?.error;
return (
<Section>
<ul className={styles.list}>
<li
onClick={() => {
router.push("/configuration/datadog");
}}
className={clsx(styles.item, {
[styles.selected]: activeProvider === "datadog",
})}
>
<div>
<Image alt="Datadog" width={100} src={DatadogLogo} />
</div>
{isDatadogConfigured && !isDatadogError && (
<div>
<Done color="secondary" />
</div>
)}
{isDatadogError && (
<div>
<Error color="error" />
</div>
)}
</li>
<li className={styles.disabledItem}>
<div>
<Typography variant="caption">Coming Soon</Typography>
</div>
</li>
<li className={styles.disabledItem}>
<div>
<Image alt="New Relic" width={100} src={NewRelicLogo} />
</div>
</li>
<li className={styles.disabledItem}>
<div>
<Image alt="Logz.io" width={100} src={LogzLogo} />
</div>
</li>
</ul>
</Section>
);
};

View file

@ -0,0 +1,6 @@
import { Paper, PaperProps } from "@material-ui/core";
import React from "react";
export const Section = (props: PaperProps) => (
<Paper {...props} elevation={0} style={{ padding: 20 }} />
);

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
},
});

View file

@ -287,6 +287,87 @@ importers:
pretty-quick: 3.1.3_prettier@2.8.3
typescript: 4.9.5
apps/monitoring:
specifiers:
'@graphql-codegen/cli': 2.13.3
'@graphql-codegen/introspection': 2.2.1
'@graphql-codegen/typed-document-node': ^2.3.3
'@graphql-codegen/typescript': 2.7.3
'@graphql-codegen/typescript-operations': 2.5.3
'@graphql-codegen/typescript-urql': ^3.7.0
'@graphql-codegen/urql-introspection': 2.2.1
'@graphql-typed-document-node/core': ^3.1.1
'@material-ui/core': ^4.12.4
'@material-ui/icons': ^4.11.3
'@material-ui/lab': 4.0.0-alpha.61
'@saleor/app-sdk': 0.27.1
'@saleor/apps-shared': workspace:*
'@saleor/macaw-ui': ^0.7.2
'@testing-library/react': ^13.4.0
'@testing-library/react-hooks': ^8.0.1
'@types/node': ^18.11.18
'@types/react': ^18.0.26
'@types/react-dom': ^18.0.10
'@urql/exchange-auth': ^1.0.0
'@vitejs/plugin-react': ^3.0.1
clsx: ^1.2.1
eslint: 8.31.0
eslint-config-next: 13.1.2
eslint-config-prettier: ^8.6.0
eslint-config-saleor: workspace:*
graphql: ^16.6.0
graphql-tag: ^2.12.6
jsdom: ^20.0.3
next: 13.1.2
prettier: ^2.8.2
react: 18.2.0
react-dom: 18.2.0
react-hook-form: ^7.42.1
typescript: 4.9.4
urql: ^3.0.3
vite: ^4.0.4
vitest: ^0.27.1
dependencies:
'@material-ui/core': 4.12.4_5ndqzdd6t4rivxsukjv3i3ak2q
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te
'@saleor/app-sdk': 0.27.1_7jnwqgtpcnwg4nzft4b6xlzlfi
'@saleor/apps-shared': link:../../packages/shared
'@saleor/macaw-ui': 0.7.2_pmlnlm755hlzzzocw2qhf3a34e
'@urql/exchange-auth': 1.0.0_graphql@16.6.0
'@vitejs/plugin-react': 3.1.0_vite@4.1.1
clsx: 1.2.1
graphql: 16.6.0
graphql-tag: 2.12.6_graphql@16.6.0
jsdom: 20.0.3
next: 13.1.2_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-hook-form: 7.43.1_react@18.2.0
urql: 3.0.3_onqnqwb3ubg5opvemcqf7c2qhy
vite: 4.1.1_@types+node@18.13.0
vitest: 0.27.3_jsdom@20.0.3
devDependencies:
'@graphql-codegen/cli': 2.13.3_d3dx4krdt4fsynqrp5lqxelwe4
'@graphql-codegen/introspection': 2.2.1_graphql@16.6.0
'@graphql-codegen/typed-document-node': 2.3.13_graphql@16.6.0
'@graphql-codegen/typescript': 2.7.3_graphql@16.6.0
'@graphql-codegen/typescript-operations': 2.5.3_graphql@16.6.0
'@graphql-codegen/typescript-urql': 3.7.3_sy4knu3obj4ys7pjcqbyfxmqle
'@graphql-codegen/urql-introspection': 2.2.1_graphql@16.6.0
'@graphql-typed-document-node/core': 3.1.1_graphql@16.6.0
'@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y
'@testing-library/react-hooks': 8.0.1_5ndqzdd6t4rivxsukjv3i3ak2q
'@types/node': 18.13.0
'@types/react': 18.0.27
'@types/react-dom': 18.0.10
eslint: 8.31.0
eslint-config-next: 13.1.2_iukboom6ndih5an6iafl45j2fe
eslint-config-prettier: 8.6.0_eslint@8.31.0
eslint-config-saleor: link:../../packages/eslint-config-saleor
prettier: 2.8.3
typescript: 4.9.4
apps/products-feed:
specifiers:
'@graphql-codegen/cli': 2.13.3
@ -3884,6 +3965,27 @@ packages:
- supports-color
dev: true
/@saleor/app-sdk/0.27.1_7jnwqgtpcnwg4nzft4b6xlzlfi:
resolution: {integrity: sha512-ZNbucokKCdBE1qa+YLHvjBVazYcRuUExBdaPW9aNxfeYyXgQNCdHqJx9oA/S1lMEVSbZSIRcn8Sx1+X/eEV8BA==}
peerDependencies:
next: '>=12'
react: '>=17'
react-dom: '>=17'
dependencies:
debug: 4.3.4
fast-glob: 3.2.12
graphql: 16.6.0
jose: 4.11.4
next: 13.1.2_biqbaboplfbrettd7655fr4n2y
raw-body: 2.5.1
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
retes: 0.33.0
uuid: 8.3.2
transitivePeerDependencies:
- supports-color
dev: false
/@saleor/app-sdk/0.29.0_3vryta7zmbcsw4rrqf4axjqggm:
resolution: {integrity: sha512-GWVLv1E8JPP8ieq9PQz5vfR5ZZ1Bo8RAkc5WaMHP/XxbPH7R8WwIGZJEkYD/kaLus3xk3DZKmA7tpzXtHRPwgw==}
peerDependencies:

View file

@ -90,6 +90,25 @@
"NEXT_PUBLIC_VERCEL_ENV"
]
},
"build#saleor-app-monitoring": {
"env": [
"APL",
"APP_DEBUG",
"NODE_ENV",
"SECRET_KEY",
"ALLOWED_DOMAIN_PATTERN",
"REST_APL_ENDPOINT",
"REST_APL_TOKEN",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_ORG",
"SENTRY_PROJECT",
"SENTRY_AUTH_TOKEN",
"NEXT_PUBLIC_VERCEL_ENV",
"MONITORING_APP_API_URL"
]
},
"lint": {
"outputs": []
},