diff --git a/.github/workflows/event-webhooks-local-flask.yaml b/.github/workflows/event-webhooks-local-flask.yaml new file mode 100644 index 0000000..ef40da9 --- /dev/null +++ b/.github/workflows/event-webhooks-local-flask.yaml @@ -0,0 +1,37 @@ +name: Events Over Webhooks Local Flask App Tests + +# Taken from https://github.com/actions/starter-workflows/blob/main/ci/python-app.yml + +on: + push: + branches: [ main ] + pull_request: + types: [opened, edited, synchronize] + +permissions: + contents: read + +jobs: + test: + + runs-on: ubuntu-20.04 + name: Quality Checks + Tests + defaults: + run: + working-directory: ./examples/event-webhooks-local-flask + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install requirements + run: pip install -r requirements.txt + - name: Install dev requirements + run: pip install -r dev_requirements.txt + - name: Lint + run: ruff check . + - name: Mypy + run: mypy . + - name: Run Tests + run: pytest tests/unit \ No newline at end of file diff --git a/README.md b/README.md index 43ea406..1220c3a 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,23 @@ in a local development environment running [Flask](https://flask.palletsprojects * User Feedback via [App Status](https://docs.benchling.com/docs/introduction-to-app-status) * Data Mapping via [App Config](https://docs.benchling.com/docs/app-configuration) * Receiving and verifying [Webhooks](https://docs.benchling.com/docs/getting-started-with-webhooks) -* Creating [molecule custom entities](https://benchling.com/api/reference#/Molecules/createMolecule) \ No newline at end of file +* Creating [molecule custom entities](https://benchling.com/api/reference#/Molecules/createMolecule) + +## event-webhooks-local-flask + +Demonstrates creating a custom UI in Benchling allowing users to search for +molecules from [PubChem](https://pubchem.ncbi.nlm.nih.gov/) and sync them into Benchling. + +Uses [localtunnel](https://localtunnel.me/) and [Docker](https://www.docker.com/) to receive webhooks +in a local development environment running [Flask](https://flask.palletsprojects.com/) with the +[Benchling SDK](https://docs.benchling.com/docs/getting-started-with-the-sdk). Syncs data from Benchling +to [PostgreSQL](https://www.postgresql.org/), an external database. + +![image info](./examples/event-webhooks-local-flask/docs/demo-full.gif) + +**Code Includes:** +* Benchling App Authentication via [Client Credentials](https://docs.benchling.com/docs/getting-started-benchling-apps#getting-credentials) +* App Activity Feedback via [App Status](https://docs.benchling.com/docs/introduction-to-app-status) +* Data Mapping via [App Config](https://docs.benchling.com/docs/app-configuration) +* Receiving and verifying [Webhooks](https://docs.benchling.com/docs/getting-started-with-webhooks) +* Filtering a `v2.entity.registered` Event from Benchling delivered by webhook \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/.devcontainer/Dockerfile b/examples/event-webhooks-local-flask/.devcontainer/Dockerfile new file mode 100644 index 0000000..e28bfd6 --- /dev/null +++ b/examples/event-webhooks-local-flask/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11 + +# Add non-root user for security +ARG USERNAME=nonroot +RUN groupadd --gid 1000 $USERNAME && useradd --uid 1000 --gid 1000 -m $USERNAME +## Make sure to reflect new user in PATH +ENV PATH="/home/${USERNAME}/.local/bin:${PATH}" +USER $USERNAME + +# Install pre-requirements +RUN pip install pip~=23.3.2 setuptools~=69.0.3 + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY dev_requirements.txt ./ +RUN pip install -r dev_requirements.txt \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/.devcontainer/devcontainer.json b/examples/event-webhooks-local-flask/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5c3e7da --- /dev/null +++ b/examples/event-webhooks-local-flask/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "runArgs": [ + "--network=host" + ], + "remoteUser": "nonroot", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.mypy-type-checker", + "charliermarsh.ruff" + ], + "settings": { + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.editor.defaultFormatter": "charliermarsh.ruff", + "python.linting.mypy": true, + "python.linting.enabled": true, + "explorer.excludeGitIgnore": true, + "python.analysis.packageIndexDepths": [ + {"name": "benchling_sdk", "depth": 8, "includeAllSymbols": true} + ] + } + } + } +} \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/.gitignore b/examples/event-webhooks-local-flask/.gitignore new file mode 100644 index 0000000..2d607bb --- /dev/null +++ b/examples/event-webhooks-local-flask/.gitignore @@ -0,0 +1,12 @@ +.idea +.vscode +**/.venv/ +**/*.egg-info +**/__pycache__ +**/.env +.DS_Store +**/.client_secret +**/.database_password +**/.pytest_cache +.mypy_cache +.ruff_cache \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/Dockerfile b/examples/event-webhooks-local-flask/Dockerfile new file mode 100644 index 0000000..4d288b8 --- /dev/null +++ b/examples/event-webhooks-local-flask/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11 + +# Install pre-requirements +RUN pip install pip~=23.3.2 setuptools~=69.0.3 + +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY ./local_app /src/local_app +WORKDIR /src/local_app +CMD ["flask", "run", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/Dockerfile.localtunnel b/examples/event-webhooks-local-flask/Dockerfile.localtunnel new file mode 100644 index 0000000..2a7d9a7 --- /dev/null +++ b/examples/event-webhooks-local-flask/Dockerfile.localtunnel @@ -0,0 +1,5 @@ +FROM node:21-alpine +RUN npm install -g localtunnel@2.0.2 + +# Note: This might not work with some networks. For instance, random airplane WiFi +CMD lt --port 5000 --local-host benchling-app --print-requests \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/README.md b/examples/event-webhooks-local-flask/README.md new file mode 100644 index 0000000..d49f99b --- /dev/null +++ b/examples/event-webhooks-local-flask/README.md @@ -0,0 +1,274 @@ +# Benchling App Example: Entity Registered Event Webhook Data Sync + +An example Benchling App written in Python which demonstrates receiving a `v2.entity.registered` +event from Benchling via a webhook subscription. The data is retrieved from the API and written +to an external database. + +![image info](./docs/demo-full.gif) + +**Appendices**: +* [Architecture Diagram](#architecture-diagram) + +## Technical Prerequisites + +This app is optimized as a minimal local development experience using [Docker](https://www.docker.com/) for reproducibility. + +> ⚠️ **Development Only**: This example is not meant to be copied into production as-is. There are additional deployment, scale, and security concerns that should be addressed before deploying an app based on this example to production. + +> ⚠️ **Scale Note**: This App responds to a single `v2.entity.registered` event. For high throughput, bulk use cases this can easily result in thousands of events delivered simultaneously. Systems will need to take volume into account and ensure that the App is throttling or batching calls appropriately (e.g., via a queue) to avoid overwhelming rate limits. + +It relies on a few other tools that will be installed for you within Docker containers: +* [Localtunnel](https://localtunnel.me/) - expose a public webhook URL and forward the results locally. ⚠️ *Not for production or real data!* +* [Flask](https://flask.palletsprojects.com/) - A simple Python web application framework +* [PostgreSQL](https://www.postgresql.org/) - A free, open source database representing an external data store example + +## Getting Started + +Create an empty placeholder file for Docker secrets. *nix example: + +```bash +touch .client_secret +touch .database_password +``` + +Windows example: + +```cmd +echo.> .client_secret +echo.> .database_password +``` + +A database password can be created now. It can be any valid PostgreSQL password. Generate and store a secure password in `.database_password`. For local development, you could open the file and manually enter any valid password. + +In production, be sure to generate and store passwords securely. Do not use this file for storing passwords. + +> ⚠️ **Security Note:** Be sure to avoid committing `.database_password` to a source code repository. + +Start Docker: + +```bash +docker compose up --build -d +``` + +Tip: You can omit the `-d` option if you want to run in the foreground. Otherwise, use `docker compose logs -f` to tail logs. + +> ℹ️ **Windows Note 1:** "Use ContainerD for pulling and storing images" may need to be enabled in `Docker > Settings > Features in development > Beta Features` + +> ℹ️ **Windows Note 2**: If running into an error like "ERROR: request returned Bad Gateway for API route and version", [this solution](https://github.com/docker/for-mac/issues/6956#issuecomment-1876444658) may fix the problem. + + +You can verify that Flask is up and running: + +```bash +curl localhost:8000/health +``` + +If Flask is running, you should see `OK` printed. + +Be sure to note the URL created for you by `localtunnel`. The log line should look something like this: + +``` +local-tunnel-1 | your url is: https://brave-wombats-poke.loca.lt +``` + +On *nix systems, you can easily obtain _just_ the URL via: + +``` +docker compose logs local-tunnel | grep -o https://.* | tail -n 1 +``` + +Example Output: + +``` +https://brave-wombats-poke.loca.lt +``` + +> 💡 Don't forget to append `/1/webhooks`, making the full URL given to Benchling `https://brave-wombats-poke.loca.lt/1/webhooks` + +## Setting Up Your App in Benchling + +### Benchling Prerequisites +1. Access to a Benchling tenant, like `https://my-tenant.benchling.com` +2. Ensure you've been granted access to the [Benchling Developer Platform Capability](https://help.benchling.com/hc/en-us/articles/9714802977805-Access-the-Benchling-Developer-Platform). +3. This example also requires Events delivered by Webhooks to be enabled on your tenant. Reach out to Benchling support to find out more about participating in early access. +4. [Global Apps](https://docs.benchling.com/docs/global-apps-faq) will need to be enabled on your tenant. + +### Upload the App Manifest + +Click the user icon in the bottom left corner to bring up the main menu. Select "Feature Settings" > "Developer Console" + +Next, click the "Create app" button and choose "From manifest." + +When prompted to upload a file, select `manifest.yaml` and click "Create." + +![image info](./docs/create-app.gif) + +### Update the Webhook URL + +Every time we restart the `local-tunnel` Docker container, it will provision +a new public webhook URL. + +Update the Benchling App's Webhook URL in the UI with the new server and +append the path our Flask route expects (see `local_app/app.py`). + +For example, if our `localtunnel` generated URL is `https://hot-ideas-doubt.loca.lt`, +the webhook URL in Benchling should be: + +``` +https://hot-ideas-doubt.loca.lt/1/webhooks +``` + +![image info](./docs/update-webhook-url.gif) + +### Generating a Client Secret + +Generate a client secret in Benchling and be sure to copy the secret. + +![image info](./docs/generate-secret.gif) + +Since the client secret is sensitive, it's handled a bit differently. It's +registered as a `secret` in our `docker-compose.yaml` file, which will be looking +for a file `./client_secret`. + +We can create this file and paste in the secret plaintext value if we have the secret in our clipboard. +On *nix: + +```bash +touch .client_secret +pbpaste > .client_secret +``` + +> ⚠️ **Security Note:** Be sure to avoid committing `.client_secret` to a source code repository. + +You'll then need to restart _just_ the `benchling-app` Docker service to pick up the changes: + +```bash +docker-compose up -d +``` + +If you restart both containers, be sure to update your App in Benchling with the new webhook URL from localtunnel. + +### Setting Client ID + +Our App needs a Client ID to pair with the Client Secret for authentication to Benchling. In this case, we've created our +App to accept `CLIENT_ID` as an environment variable. + +One easy way to set an environment variables for Docker is to add a `.env` file. + +```bash +touch .env +``` + +Windows example: + +```cmd +echo.> .env +``` + +Open it in an editor of your choice and set the values with the plaintext client ID +for your App. For example: + +``` +CLIENT_ID=Ts7jtwPohM +``` + +### Setting App Definition ID + +The App definition ID is available from the Developer Console by selecting the App to view. + +![image info](./docs/global-app-definition-id.png) + +> ℹ️ **Note:** If you do NOT see this ID, please ensure [Global Apps](https://docs.benchling.com/docs/global-apps-faq) are enabled for your tenant. + +Add it to your `.env` file with a variable name `APP_DEFINITION_ID`. The contents of your `.env` file should now look something like: + +``` +CLIENT_ID=Ts7jtwPohM +APP_DEFINITION_ID=appdef_Trow4zbR3o +``` + +### Restarting the Container to Reflect Environment Changes + +Restart the `benchling-app` Docker container to pick up the environment changes. + +```bash +docker-compose up -d +``` + +### Security Note: Storing App Secrets in Production + +> ⚠️ **Security Note:** In production, store the secret with a secure solution such as a secrets store (AWS Secrets Manager, as an example) or, if storing programmatically, encrypted using app-layer encryption. Avoid placing it in plaintext anywhere in code or configuration. + +### Create App Registry Dependencies + +If you examine the `configuration` section of `manifest.yaml`, you'll see our App +expects a few configuration items: +1. A custom entity schema with one text field + +#### Custom Entity Schema + +Create the entity schema in the tenant's registry. If you do not have access to +the registry, you can ask your tenant administrator to do this for you. + +![image info](./docs/create-entity-schema.gif) + +The created custom entity schema should look something like this: + +![image info](./docs/schema-example.png) + +_Note: The names can be different, and the schema is allowed to have additional fields. +As long as it's for a Custom entity, and has at least one `Text` field._ + +### Updating the App's Configuration + +App Configuration gives us a stable code contract for referencing data mapped in a Benchling tenant. +The values of the data in Benchling can then be changed without updating App code. + +Let's update our configuration to: +1. Link a custom entity schema and field for the synced data + +![image info](./docs/update-app-config.gif) + +### Permission the App + +By default, Benchling Apps do not have permission to any data in Benchling. +Let's grant some access by adding the Benchling App to an organization. + +![image info](./docs/permission-app.gif) + +## Running the App - Receiving an Event and Syncing Data + +1. Create a new custom entity of the schema specified in App Config +2. Register the custom entity + +### Verification + +Verify that the App received the event and synced the data in several ways: + +#### Inspect the App Activity logs in Benchling + +#### Inspect the Flask container logs + +Example: + +```bash +docker compose logs -f +``` + +![image info](./docs/docker-logs.png) + +#### Connect to the database and observe the synced record + +For example, with the `psql` command line tool: + +1. `psql -h localhost -p 5438 -U postgres -d benchling` +2. (enter password) +3. `select * from synced_benchling_data;` + +![image info](./docs/postgresql.png) + +## Appendices + +### Architecture Diagram + +![image info](./docs/architecture-diagram.png) diff --git a/examples/event-webhooks-local-flask/dev_requirements.txt b/examples/event-webhooks-local-flask/dev_requirements.txt new file mode 100644 index 0000000..6210ef4 --- /dev/null +++ b/examples/event-webhooks-local-flask/dev_requirements.txt @@ -0,0 +1,6 @@ +# Only for maintainers +ruff~=0.1.11 +pytest~=7.4.4 +mypy~=1.8.0 +pytest-postgresql~=6.0.0 +types-psycopg2==2.9.21.20240311 \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/docker-compose.yaml b/examples/event-webhooks-local-flask/docker-compose.yaml new file mode 100644 index 0000000..200a4c8 --- /dev/null +++ b/examples/event-webhooks-local-flask/docker-compose.yaml @@ -0,0 +1,61 @@ +version: "3.8" + +services: + benchling-app: + build: . + ports: + - "8000:5000" + volumes: + - type: bind + source: . + target: /src + environment: + # Don't set debug in production! + - FLASK_DEBUG=1 + # Enable debug logging for Python Benchling App. This is not native, specific to this example implementation + - BENCHLING_APP_LOG_LEVEL=DEBUG + # Client ID is not sensitive and is the same across all tenants, so could be hard-coded + # You might choose to have two different Apps for dev vs prod, which would have different client IDs + - CLIENT_ID + - APP_DEFINITION_ID + # Client secret for the Benchling App, stored somewhere securely in production. + # Injected here for convenience. Each Client ID will have its own Client secret + - CLIENT_SECRET_FILE=/run/secrets/app_client_secret + # Similarly, the App needs to know the external database password to connect + - POSTGRES_PASSWORD_FILE=/run/secrets/database_password + secrets: + - app_client_secret + - database_password + + # FOR LOCAL DEVELOPMENT ONLY! + # Free tool for providing a public URL to forward webhooks to our Benchling App running locally + # Do not do this in production or use with any sensitive data. + # Benchling has not vetted this tool for use in production or in sensitive systems. + # Conduct your own due diligence before choosing a tool for production use. + local-tunnel: + build: + context: . + dockerfile: Dockerfile.localtunnel + + # Not required for Benchling use, just using PostgreSQL as an example data store + # for syncing Benchling data to an external source + external-database: + image: postgres:16.2 + restart: always + # Map port to avoid conflict with common default on developer machines + ports: + - "5438:5432" + volumes: + # SQL script to create tables + - ./scripts/create_tables.sql:/docker-entrypoint-initdb.d/create_tables.sql + environment: + - POSTGRES_DB=benchling + - POSTGRES_PASSWORD_FILE=/run/secrets/database_password + secrets: + - database_password + +secrets: + app_client_secret: + file: .client_secret + database_password: + file: .database_password diff --git a/examples/event-webhooks-local-flask/docs/architecture-diagram.png b/examples/event-webhooks-local-flask/docs/architecture-diagram.png new file mode 100644 index 0000000..e39a7af Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/architecture-diagram.png differ diff --git a/examples/event-webhooks-local-flask/docs/create-app.gif b/examples/event-webhooks-local-flask/docs/create-app.gif new file mode 100644 index 0000000..ae447df Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/create-app.gif differ diff --git a/examples/event-webhooks-local-flask/docs/create-entity-schema.gif b/examples/event-webhooks-local-flask/docs/create-entity-schema.gif new file mode 100644 index 0000000..b206728 Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/create-entity-schema.gif differ diff --git a/examples/event-webhooks-local-flask/docs/demo-full.gif b/examples/event-webhooks-local-flask/docs/demo-full.gif new file mode 100644 index 0000000..3c6ae77 Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/demo-full.gif differ diff --git a/examples/event-webhooks-local-flask/docs/docker-logs.png b/examples/event-webhooks-local-flask/docs/docker-logs.png new file mode 100644 index 0000000..26f8f64 Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/docker-logs.png differ diff --git a/examples/event-webhooks-local-flask/docs/generate-secret.gif b/examples/event-webhooks-local-flask/docs/generate-secret.gif new file mode 100644 index 0000000..85c2b34 Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/generate-secret.gif differ diff --git a/examples/event-webhooks-local-flask/docs/global-app-definition-id.png b/examples/event-webhooks-local-flask/docs/global-app-definition-id.png new file mode 100644 index 0000000..38d4dec Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/global-app-definition-id.png differ diff --git a/examples/event-webhooks-local-flask/docs/permission-app.gif b/examples/event-webhooks-local-flask/docs/permission-app.gif new file mode 100644 index 0000000..428831f Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/permission-app.gif differ diff --git a/examples/event-webhooks-local-flask/docs/postgresql.png b/examples/event-webhooks-local-flask/docs/postgresql.png new file mode 100644 index 0000000..4fe40cd Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/postgresql.png differ diff --git a/examples/event-webhooks-local-flask/docs/schema-example.png b/examples/event-webhooks-local-flask/docs/schema-example.png new file mode 100644 index 0000000..32eeb6c Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/schema-example.png differ diff --git a/examples/event-webhooks-local-flask/docs/update-app-config.gif b/examples/event-webhooks-local-flask/docs/update-app-config.gif new file mode 100644 index 0000000..37e9f38 Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/update-app-config.gif differ diff --git a/examples/event-webhooks-local-flask/docs/update-webhook-url.gif b/examples/event-webhooks-local-flask/docs/update-webhook-url.gif new file mode 100644 index 0000000..a646b0b Binary files /dev/null and b/examples/event-webhooks-local-flask/docs/update-webhook-url.gif differ diff --git a/examples/event-webhooks-local-flask/local_app/__init__.py b/examples/event-webhooks-local-flask/local_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/local_app/app.py b/examples/event-webhooks-local-flask/local_app/app.py new file mode 100644 index 0000000..767decc --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/app.py @@ -0,0 +1,46 @@ +from threading import Thread + +from benchling_sdk.apps.helpers.webhook_helpers import verify +from flask import Flask, request + +from local_app.benchling_app.handler import handle_webhook +from local_app.benchling_app.setup import app_definition_id +from local_app.lib.logger import get_logger + +logger = get_logger() + + +def create_app() -> Flask: + app = Flask("benchling-app") + + @app.route("/health") + def health_check() -> tuple[str, int]: + # Just a route allowing us to check that Flask itself is up and running + return "OK", 200 + + @app.route("/1/webhooks/", methods=["POST"]) + def receive_webhooks(target: str) -> tuple[str, int]: # noqa: ARG001 + # For security, don't do anything else without first verifying the webhook + app_def_id = app_definition_id() + + # Important! To verify webhooks, we need to pass the body as an unmodified string + # Flask's request.data is bytes, so decode to string. Passing bytes or JSON won't work + verify(app_def_id, request.data.decode("utf-8"), request.headers) + + logger.debug("Received webhook message: %s", request.json) + # Dispatch work and ACK webhook as quickly as possible + _enqueue_work() + # ACK webhook by returning 2xx status code so Benchling knows the app received the signal + return "OK", 200 + + return app + + +def _enqueue_work() -> None: + # PRODUCTION NOTE: A high volume of webhooks may spawn too many threads and lead to processing failures + # In production, we recommend a more robust queueing system for scale + thread = Thread( + target=handle_webhook, + args=(request.json,), + ) + thread.start() diff --git a/examples/event-webhooks-local-flask/local_app/benchling_app/__init__.py b/examples/event-webhooks-local-flask/local_app/benchling_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/local_app/benchling_app/handler.py b/examples/event-webhooks-local-flask/local_app/benchling_app/handler.py new file mode 100644 index 0000000..0d4377e --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/benchling_app/handler.py @@ -0,0 +1,45 @@ +from typing import Any + +from benchling_sdk.apps.framework import App +from benchling_sdk.models.webhooks.v0 import ( + EntityRegisteredWebhookV2, + WebhookEnvelopeV0, +) + +from local_app.benchling_app.setup import init_app_from_webhook +from local_app.benchling_app.sync_data import sync_event_data +from local_app.lib.logger import get_logger + +logger = get_logger() + + +class UnsupportedWebhookError(Exception): + pass + + +def handle_webhook(webhook_dict: dict[str, Any]) -> None: + logger.debug("Handling webhook with payload: %s", webhook_dict) + webhook = WebhookEnvelopeV0.from_dict(webhook_dict) + app = init_app_from_webhook(webhook) + # Could also choose to route on webhook.message.type + if isinstance(webhook.message, EntityRegisteredWebhookV2): + # Since we'll be receiving ALL entity registrations, add a filter to only + # work with events that meet our criteria (e.g., specific schema) + if _is_target_event(app, webhook.message): + sync_event_data(app, webhook.message) + else: + logger.debug("Discarded event and exiting: %s", webhook.message) + return + else: + # Should only happen if the app's manifest requests webhooks that aren't handled in its code paths + raise UnsupportedWebhookError(f"Received an unsupported webhook type: {webhook}") + logger.debug("Successfully completed request for webhook: %s", webhook_dict) + + +def _is_target_event(app: App, webhook: EntityRegisteredWebhookV2) -> bool: + # .required().value_str() are only needed for type safety checks like MyPy + # If type safety isn't a concern: + # `app.config_store.config_by_path(["Synced Schema"]).value` + target_schema_id = app.config_store.config_by_path(["Synced Schema"]).required().value_str() + assert webhook.schema + return webhook.schema.id == target_schema_id diff --git a/examples/event-webhooks-local-flask/local_app/benchling_app/setup.py b/examples/event-webhooks-local-flask/local_app/benchling_app/setup.py new file mode 100644 index 0000000..f6dc61b --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/benchling_app/setup.py @@ -0,0 +1,47 @@ +import os +from functools import cache +from pathlib import Path + +from benchling_sdk.apps.framework import App +from benchling_sdk.auth.client_credentials_oauth2 import ClientCredentialsOAuth2 +from benchling_sdk.benchling import Benchling +from benchling_sdk.models.webhooks.v0 import ( + WebhookEnvelopeV0, +) + + +def init_app_from_webhook(webhook: WebhookEnvelopeV0) -> App: + return App(webhook.app.id, _benchling_from_webhook(webhook)) + + +@cache +def app_definition_id() -> str: + # App definition ID is available to "global" apps. It uniquely identifies the Benchling App + # above the tenant context. + # + # Although it is available via the webhook, for security purposes we choose to supply it with + # the App's code to avoid reusing elements of the webhook's data payload as part of its verification. + # For ease of setup, we retrieve it from an environment variable. + # You could choose to simply leave it in code like `return "appdef_SpzX0d5oDA"`. + app_def_id = os.environ.get("APP_DEFINITION_ID") + assert app_def_id is not None, "Missing APP_DEFINITION_ID from environment" + return app_def_id + + +def _benchling_from_webhook(webhook: WebhookEnvelopeV0) -> Benchling: + return Benchling(webhook.base_url, _auth_method()) + + +@cache +def _auth_method() -> ClientCredentialsOAuth2: + client_id = os.environ.get("CLIENT_ID") + assert client_id is not None, "Missing CLIENT_ID from environment" + client_secret = _client_secret_from_file() + return ClientCredentialsOAuth2(client_id, client_secret) + + +def _client_secret_from_file() -> str: + file_path = os.environ.get("CLIENT_SECRET_FILE") + assert file_path is not None, "Missing CLIENT_SECRET_FILE from environment" + with Path(file_path).open() as f: + return f.read() diff --git a/examples/event-webhooks-local-flask/local_app/benchling_app/sync_data.py b/examples/event-webhooks-local-flask/local_app/benchling_app/sync_data.py new file mode 100644 index 0000000..77cc43b --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/benchling_app/sync_data.py @@ -0,0 +1,40 @@ +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.status.helpers import ref +from benchling_sdk.models import ( + AppSessionMessageCreate, + AppSessionMessageStyle, + AppSessionUpdateStatus, + CustomEntity, +) +from benchling_sdk.models.webhooks.v0 import ( + EntityRegisteredWebhookV2, +) + +from local_app.lib.postgresql import write_data + + +def sync_event_data(app: App, entity_registered: EntityRegisteredWebhookV2) -> None: + with app.create_session_context( + name=f"Sync entity {entity_registered.resource_id}", timeout_seconds=30, + ) as session: + # Events contain pointers to data, so fetch the full entity data from the API + entity = app.benchling.custom_entities.get_by_id(entity_registered.resource_id) + synced_id = _sync_entity(app, entity) + session.close_session( + status=AppSessionUpdateStatus.SUCCEEDED, + messages=[ + AppSessionMessageCreate( + f"Synced {ref(entity)} into external database with ID {synced_id}", + style=AppSessionMessageStyle.SUCCESS, + ), + ], + ) + + +def _sync_entity(app: App, entity: CustomEntity) -> str: + field_name = app.config_store.config_by_path( + ["Synced Schema", "Synced Field Data"], + ).required().linked_resource().name + field_value = entity.fields[field_name].value + field_value = str(field_value) if field_value else None + return write_data(entity.name, entity.id, field_value) diff --git a/examples/event-webhooks-local-flask/local_app/lib/__init__.py b/examples/event-webhooks-local-flask/local_app/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/local_app/lib/logger.py b/examples/event-webhooks-local-flask/local_app/lib/logger.py new file mode 100644 index 0000000..fed2224 --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/lib/logger.py @@ -0,0 +1,14 @@ +import logging +import os + +# Enable basic logging for development debugging +logging.basicConfig(format="%(name)s - %(asctime)s - {%(pathname)s:%(lineno)d} - %(message)s", + level=logging.WARNING) + + +# Get a logger for our App, with a level enabled specifically for our logging but not other libraries +def get_logger(name: str = "benchling-app") -> logging.Logger: + level = os.environ.get("BENCHLING_APP_LOG_LEVEL", "INFO").upper() + logger = logging.getLogger(name) + logger.setLevel(level) + return logger diff --git a/examples/event-webhooks-local-flask/local_app/lib/postgresql.py b/examples/event-webhooks-local-flask/local_app/lib/postgresql.py new file mode 100644 index 0000000..076da75 --- /dev/null +++ b/examples/event-webhooks-local-flask/local_app/lib/postgresql.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path + +import psycopg2 + +from local_app.lib.logger import get_logger + +logger = get_logger() + + +def write_data(entity_name: str, benchling_api_id: str, field_value: str | None) -> str: + with _connection() as conn, conn.cursor() as cursor: + insert_query = """ + INSERT INTO synced_benchling_data + (entity_name, benchling_api_id, field_value) VALUES (%s, %s, %s) + RETURNING synced_entity_id + """ + insert_data = (entity_name, benchling_api_id, field_value) + logger.debug("Inserting entity data as: %s", insert_data) + # Security note: psycopg2 should sanitize user input to protect from SQL injection, etc. + cursor.execute(insert_query, insert_data) + result = cursor.fetchone() + # Appease MyPy type checking + assert result is not None + synced_id = result[0] + conn.commit() + return synced_id + + +def _connection() -> psycopg2.extensions.connection: + return psycopg2.connect( + database="benchling", + user="postgres", + host="external-database", + port=5432, + password=_database_password_from_file(), + ) + + +def _database_password_from_file() -> str: + file_path = os.environ.get("POSTGRES_PASSWORD_FILE") + assert file_path is not None, "Missing POSTGRES_PASSWORD_FILE from environment" + with Path(file_path).open() as f: + return f.read() diff --git a/examples/event-webhooks-local-flask/manifest.yaml b/examples/event-webhooks-local-flask/manifest.yaml new file mode 100644 index 0000000..c2f9114 --- /dev/null +++ b/examples/event-webhooks-local-flask/manifest.yaml @@ -0,0 +1,25 @@ +manifestVersion: 1 + +info: + name: Webhook Events App + description: | + Example App receiving entity registered events from Benchling and syncing + them into an external database. + version: 0.1.0 + +# Subscribe to entity registered events +subscriptions: + deliveryMethod: WEBHOOK + messages: + - type: v2.entity.registered + +configuration: + - name: Synced Schema + type: entity_schema + subtype: custom_entity + description: The entity schema we want to sync into an external database + requiredConfig: true + fieldDefinitions: + - name: Synced Field Data + type: text + requiredConfig: true diff --git a/examples/event-webhooks-local-flask/requirements.txt b/examples/event-webhooks-local-flask/requirements.txt new file mode 100644 index 0000000..6117376 --- /dev/null +++ b/examples/event-webhooks-local-flask/requirements.txt @@ -0,0 +1,5 @@ +flask~=3.0.2 +# Cryptography extra needed for webhook verification +benchling-sdk[cryptography]==1.16.0 +# For connecting to PostgreSQL in this example, since we're writing data there +psycopg2-binary==2.9.9 diff --git a/examples/event-webhooks-local-flask/ruff.toml b/examples/event-webhooks-local-flask/ruff.toml new file mode 100644 index 0000000..d6bc18c --- /dev/null +++ b/examples/event-webhooks-local-flask/ruff.toml @@ -0,0 +1,10 @@ +# Run with: ruff check . --fix + +target-version = "py311" +line-length = 110 + +select = ["ALL"] +ignore = ["D100", "D101", "D103", "D104", "EM101", "EM102", "S101", "TRY003", "TRY301"] + +[per-file-ignores] +"**/tests/*" = ["ANN001", "ANN101", "D102", "PLR2004"] \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/scripts/create_tables.sql b/examples/event-webhooks-local-flask/scripts/create_tables.sql new file mode 100644 index 0000000..87a18b0 --- /dev/null +++ b/examples/event-webhooks-local-flask/scripts/create_tables.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS synced_benchling_data ( + synced_entity_id SERIAL PRIMARY KEY, + entity_name VARCHAR(300) NOT NULL, + benchling_api_id VARCHAR(30) NOT NULL, + field_value VARCHAR(300), + synced_datetime TIMESTAMPTZ DEFAULT NOW() NOT NULL +); \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/tests/__init__.py b/examples/event-webhooks-local-flask/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/tests/files/test_client_secret b/examples/event-webhooks-local-flask/tests/files/test_client_secret new file mode 100644 index 0000000..7fdf977 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/files/test_client_secret @@ -0,0 +1 @@ +fake_secret \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/tests/files/test_database_password b/examples/event-webhooks-local-flask/tests/files/test_database_password new file mode 100644 index 0000000..40820a0 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/files/test_database_password @@ -0,0 +1 @@ +fake_password \ No newline at end of file diff --git a/examples/event-webhooks-local-flask/tests/files/webhooks/app_activation_webhook.json b/examples/event-webhooks-local-flask/tests/files/webhooks/app_activation_webhook.json new file mode 100644 index 0000000..604677a --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/files/webhooks/app_activation_webhook.json @@ -0,0 +1,17 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_bcvxj2yf7q", + "app": { + "id": "app_DRBigxGEyr2BzW4T" + }, + "appDefinition": { + "id": "appdef_PRhebXCvtw", + "versionNumber": "0.0.1" + }, + "channel": "app_signals", + "message": { + "type": "v2.app.activateRequested", + "deprecated": false + } +} diff --git a/examples/event-webhooks-local-flask/tests/files/webhooks/assay_run_created_webhook.json b/examples/event-webhooks-local-flask/tests/files/webhooks/assay_run_created_webhook.json new file mode 100644 index 0000000..1546df0 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/files/webhooks/assay_run_created_webhook.json @@ -0,0 +1,24 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_pkyu4s98as", + "app": { + "id": "app_KNz7HTHIu7TSOSA3" + }, + "appDefinition": { + "id": "appdef_UG0XibU4eW", + "versionNumber": "0.1.0" + }, + "channel": "events", + "message": { + "id": "evt_MQjr2hnJ7WLS", + "createdAt": "2024-04-04T03:48:09.962068+00:00", + "type": "v2.assayRun.created", + "schema": { + "id": "assaysch_fFLKmdmG" + }, + "deprecated": false, + "resourceId": "77af3205-65af-457f-87f5-75462b85075a", + "excludedProperties": [] + } + } diff --git a/examples/event-webhooks-local-flask/tests/files/webhooks/entity_registered_webhook.json b/examples/event-webhooks-local-flask/tests/files/webhooks/entity_registered_webhook.json new file mode 100644 index 0000000..797d496 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/files/webhooks/entity_registered_webhook.json @@ -0,0 +1,24 @@ +{ + "version": "0", + "baseURL": "https://non-existent.benchling.com", + "tenantId": "ten_pkyu4s98as", + "app": { + "id": "app_KNz7HTHIu7TSOSA3" + }, + "appDefinition": { + "id": "appdef_UG0XibU4eW", + "versionNumber": "0.1.0" + }, + "channel": "events", + "message": { + "id": "evt_MQjr2hnJ7WLS", + "createdAt": "2024-04-04T03:48:09.962068+00:00", + "type": "v2.entity.registered", + "schema": { + "id": "ts_X3i4k1M0" + }, + "deprecated": false, + "resourceId": "bfi_J81Hy0ih", + "excludedProperties": [] + } + } diff --git a/examples/event-webhooks-local-flask/tests/helpers.py b/examples/event-webhooks-local-flask/tests/helpers.py new file mode 100644 index 0000000..b634a53 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/helpers.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +from benchling_sdk.models.webhooks.v0 import ( + WebhookEnvelopeV0, +) + + +def load_beta_webhook_json(file_path: Path) -> WebhookEnvelopeV0: + assert file_path.is_file(), f"Missing webhook JSON file at {file_path}" + with file_path.open() as f: + webhook_dict = json.loads(f.read()) + return WebhookEnvelopeV0.from_dict(webhook_dict) diff --git a/examples/event-webhooks-local-flask/tests/unit/__init__.py b/examples/event-webhooks-local-flask/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/__init__.py b/examples/event-webhooks-local-flask/tests/unit/local_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/__init__.py b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_handler.py b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_handler.py new file mode 100644 index 0000000..5643d71 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_handler.py @@ -0,0 +1,68 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from benchling_sdk.apps.config.mock_config import MockConfigItemStore +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.helpers.manifest_helpers import manifest_from_file +from benchling_sdk.models import EntitySchemaAppConfigItem, EntitySchemaAppConfigItemType +from benchling_sdk.models.webhooks.v0 import ( + EntityRegisteredWebhookV2, +) + +from local_app.benchling_app.handler import UnsupportedWebhookError, handle_webhook +from tests.helpers import load_beta_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files/webhooks" + + +class TestWebhookHandler: + + @patch("local_app.benchling_app.handler.sync_event_data") + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_event_created_entity_registered(self, mock_init_app_from_webhook, + mock_sync_event_data) -> None: + # Setup + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "entity_registered_webhook.json") + mock_app = MagicMock(App) + # The schema in App Config must match the webhook payload or our handler filter + # will exclude this event. By default, App Config generates random values, so in + # this case we need to set an explicit value + manifest = manifest_from_file(Path(__file__).parent.parent.parent.parent.parent / "manifest.yaml") + # This will mock all config items with random valid values + # We can override particular configs if desired. This shows an example of overriding a schema config + mock_config_store = MockConfigItemStore.from_manifest(manifest).with_replacement( + EntitySchemaAppConfigItem( + path=["Synced Schema"], + # Must match what's in tests/files/webhooks/entity_registered_webhook.json + value="ts_X3i4k1M0", + type=EntitySchemaAppConfigItemType.ENTITY_SCHEMA), + ) + mock_app.config_store = mock_config_store + mock_init_app_from_webhook.return_value = mock_app + + # Test + handle_webhook(webhook.to_dict()) + + # Verify + assert isinstance(webhook.message, EntityRegisteredWebhookV2) + mock_sync_event_data.assert_called_once_with(mock_app, webhook.message) + + @patch("local_app.benchling_app.handler.sync_event_data") + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_event_created_ignores_other_event(self, mock_init_app_from_webhook, + mock_sync_event_data) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "assay_run_created_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + with pytest.raises(UnsupportedWebhookError): + handle_webhook(webhook.to_dict()) + mock_sync_event_data.assert_not_called() + + @patch("local_app.benchling_app.handler.init_app_from_webhook") + def test_handle_webhook_unsupported(self, mock_init_app_from_webhook) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "app_activation_webhook.json") + mock_app = MagicMock(App) + mock_init_app_from_webhook.return_value = mock_app + with pytest.raises(UnsupportedWebhookError): + handle_webhook(webhook.to_dict()) diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_setup.py b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_setup.py new file mode 100644 index 0000000..df6f8b7 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_setup.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import pytest +from benchling_sdk.apps.framework import App + +from local_app.benchling_app.setup import _auth_method, app_definition_id, init_app_from_webhook +from tests.helpers import load_beta_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files/webhooks" + + +class TestBenchlingAppSetup: + + def setup_method(self) -> None: + _auth_method.cache_clear() + app_definition_id.cache_clear() + + def test_init_app_from_webhook(self, monkeypatch) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "entity_registered_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_ID", "clientId") + context.setenv("CLIENT_SECRET_FILE", str(_TEST_FILES_PATH.parent / "test_client_secret")) + result = init_app_from_webhook(webhook) + assert isinstance(result, App) + + def test_init_app_from_webhook_missing_client_id(self, monkeypatch) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "entity_registered_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_SECRET_FILE", str(_TEST_FILES_PATH.parent / "test_client_secret")) + with pytest.raises(AssertionError, match="Missing CLIENT_ID from environment"): + init_app_from_webhook(webhook) + + def test_init_app_from_webhook_missing_client_secret_file(self, monkeypatch) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "entity_registered_webhook.json") + with monkeypatch.context() as context: + context.setenv("CLIENT_ID", "clientId") + with pytest.raises(AssertionError, match="Missing CLIENT_SECRET_FILE from environment"): + init_app_from_webhook(webhook) + + def test_app_definition_id(self, monkeypatch) -> None: + with monkeypatch.context() as context: + context.setenv("APP_DEFINITION_ID", "app_def1234") + result = app_definition_id() + assert result == "app_def1234" + + def test_app_definition_id_missing_app_definition_id(self, monkeypatch) -> None: + with (monkeypatch.context(), + pytest.raises(AssertionError, match="Missing APP_DEFINITION_ID from environment")): + app_definition_id() diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_sync_data.py b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_sync_data.py new file mode 100644 index 0000000..a9639e6 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/benchling_app/test_sync_data.py @@ -0,0 +1,82 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from benchling_sdk.apps.config.mock_config import MockConfigItemStore +from benchling_sdk.apps.framework import App +from benchling_sdk.apps.helpers.manifest_helpers import manifest_from_file +from benchling_sdk.apps.status.framework import SessionContextManager +from benchling_sdk.apps.status.helpers import ref +from benchling_sdk.helpers.serialization_helpers import fields +from benchling_sdk.models import ( + AppSessionMessageCreate, + AppSessionMessageStyle, + AppSessionUpdateStatus, + CustomEntity, + FieldAppConfigItem, + FieldAppConfigItemType, + LinkedAppConfigResourceSummary, +) +from benchling_sdk.models.webhooks.v0 import ( + EntityRegisteredWebhookV2, +) + +from local_app.benchling_app.sync_data import _sync_entity, sync_event_data + + +class TestSyncData: + @patch("local_app.benchling_app.sync_data._sync_entity") + def test_sync_event_data(self, mock_sync_entity) -> None: + # Setup + entity = MagicMock(CustomEntity) + app = MagicMock(App) + mock_session_context_manager = MagicMock() + mock_session_context = MagicMock(SessionContextManager) + mock_session_context_manager.__enter__.return_value = mock_session_context + app.create_session_context.return_value = mock_session_context_manager + app.benchling.custom_entities.get_by_id.return_value = entity + mock_sync_entity.return_value = "database_id" + entity_registered = MagicMock(EntityRegisteredWebhookV2) + entity_registered.resource_id = "resource_id" + + # Test + sync_event_data(app, entity_registered) + + # Verify + app.benchling.custom_entities.get_by_id.assert_called_once_with("resource_id") + mock_sync_entity.assert_called_once_with(app, entity) + mock_session_context.close_session.assert_called_once_with( + status=AppSessionUpdateStatus.SUCCEEDED, + messages=[ + AppSessionMessageCreate( + f"Synced {ref(entity)} into external database with ID database_id", + style=AppSessionMessageStyle.SUCCESS, + ), + ], + ) + + @patch("local_app.benchling_app.sync_data.write_data") + def test_sync_entity(self, mock_write_data) -> None: + # Setup + mock_app = MagicMock(App) + mock_entity = MagicMock(CustomEntity) + mock_entity.name = "Entity Name" + mock_entity.id = "API ID" + mock_entity.fields = fields({"Configured Field": {"value": "Sync Value"}}) + manifest = manifest_from_file(Path(__file__).parent.parent.parent.parent.parent / "manifest.yaml") + # This will mock all config items with random valid values + # We can override particular configs if desired. This shows an example of overriding a folder config + mock_config_store = MockConfigItemStore.from_manifest(manifest).with_replacement( + FieldAppConfigItem( + path=["Synced Schema", "Synced Field Data"], + value="tsf_123", + type=FieldAppConfigItemType.FIELD, + linked_resource=LinkedAppConfigResourceSummary(id="tsf_123", name="Configured Field"), + ), + ) + mock_app.config_store = mock_config_store + + # Test + _sync_entity(mock_app, mock_entity) + + # Verify + mock_write_data.assert_called_once_with("Entity Name", "API ID", "Sync Value") diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/lib/__init__.py b/examples/event-webhooks-local-flask/tests/unit/local_app/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_logger.py b/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_logger.py new file mode 100644 index 0000000..6a0c1f0 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_logger.py @@ -0,0 +1,17 @@ +import logging + +from local_app.lib.logger import get_logger + + +class TestLogger: + def test_logger_with_name(self) -> None: + logger = get_logger("test-logger") + assert logger.name == "test-logger" + assert logger.level == logging.INFO + + def test_logger_with_level(self, monkeypatch) -> None: + with monkeypatch.context() as context: + context.setenv("BENCHLING_APP_LOG_LEVEL", "DEBUG") + logger = get_logger() + assert logger.name == "benchling-app" + assert logger.level == logging.DEBUG diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_postgresql.py b/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_postgresql.py new file mode 100644 index 0000000..1deb597 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/lib/test_postgresql.py @@ -0,0 +1,56 @@ + + +from pathlib import Path +from unittest import mock + +import pytest + +from local_app.lib.postgresql import _database_password_from_file, write_data + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent.parent / "files" + + +class TestPostgreSql: + # postgresql fixture provided by pytest-postgresql + def test_write_data(self, monkeypatch, postgresql) -> None: + with postgresql.cursor() as cursor: + cursor.execute(_create_table_sql_from_file()) + with monkeypatch.context() as context: + context.setenv("POSTGRES_PASSWORD_FILE", str(_TEST_FILES_PATH / "test_database_password")) + with mock.patch("local_app.lib.postgresql._connection") as mock_connection: + mock_connection.return_value = postgresql + result = write_data("Entity Name", "API ID", "Field Value") + # The connection is closed so we can't query to verify the data + # Just verify that we returned an ID. The database should be empty, so ID should be 1 + assert result == 1 + + def test_write_data_field_value_none(self, monkeypatch, postgresql) -> None: + with postgresql.cursor() as cursor: + cursor.execute(_create_table_sql_from_file()) + with monkeypatch.context() as context: + context.setenv("POSTGRES_PASSWORD_FILE", str(_TEST_FILES_PATH / "test_database_password")) + with mock.patch("local_app.lib.postgresql._connection") as mock_connection: + mock_connection.return_value = postgresql + result = write_data("Entity Name", "API ID", None) + # The connection is closed so we can't query to verify the data + # Just verify that we returned an ID. The database should be empty, so ID should be 1 + assert result == 1 + + def test_database_password_from_file(self, monkeypatch) -> None: + with monkeypatch.context() as context: + context.setenv("POSTGRES_PASSWORD_FILE", str(_TEST_FILES_PATH / "test_database_password")) + result = _database_password_from_file() + assert result == "fake_password" + + def test_database_password_from_file_missing_file(self, monkeypatch) -> None: + with ( + monkeypatch.context(), + pytest.raises(AssertionError, match="Missing POSTGRES_PASSWORD_FILE from environment"), + ): + _database_password_from_file() + + +def _create_table_sql_from_file() -> str: + create_table_sql_path = _TEST_FILES_PATH.parent.parent / "scripts/create_tables.sql" + with create_table_sql_path.open() as f: + return f.read() diff --git a/examples/event-webhooks-local-flask/tests/unit/local_app/test_app.py b/examples/event-webhooks-local-flask/tests/unit/local_app/test_app.py new file mode 100644 index 0000000..0495269 --- /dev/null +++ b/examples/event-webhooks-local-flask/tests/unit/local_app/test_app.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from local_app.app import create_app +from tests.helpers import load_beta_webhook_json + +_TEST_FILES_PATH = Path(__file__).parent.parent.parent / "files/webhooks" + + +@pytest.fixture() +def app() -> Flask: + app = create_app() + app.config.update({ + "TESTING": True, + }) + return app + + +@pytest.fixture() +def client(app: Flask) -> FlaskClient: + return app.test_client() + + +class TestApp: + + @patch("local_app.app._enqueue_work") + @patch("local_app.app.app_definition_id") + @patch("local_app.app.verify") + def test_app_receive_webhook( + self, mock_verify, mock_app_definition_id, mock_enqueue_work, client, + ) -> None: + webhook = load_beta_webhook_json(_TEST_FILES_PATH / "entity_registered_webhook.json") + response = client.post("1/webhooks/canvas", json=webhook.to_dict()) + assert response.status_code == 200 + mock_verify.assert_called_once() + mock_app_definition_id.assert_called_once() + mock_enqueue_work.assert_called_once()