Skip to content

Instantly share code, notes, and snippets.

@simonw
Created April 8, 2024 16:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save simonw/5e379cc3ed610caf8dfbdfe25b19874a to your computer and use it in GitHub Desktop.
Save simonw/5e379cc3ed610caf8dfbdfe25b19874a to your computer and use it in GitHub Desktop.

2024-04-08T16:42:11 conversation: 01htz9ec1wbnm9hapa62p2aqfw

Model: claude-3-opus-20240229

Prompt:

../asgi-cors/asgi_cors.py
---
import fnmatch
import asyncio
from functools import wraps


def asgi_cors_decorator(
    allow_all=False,
    hosts=None,
    host_wildcards=None,
    callback=None,
    headers=None,
    methods=None,
    max_age=None,
):
    hosts = hosts or []
    host_wildcards = host_wildcards or []
    headers = headers or []
    methods = methods or []

    # We need hosts and host_wildcards to be b""
    hosts = set(h.encode("utf8") if isinstance(h, str) else h for h in hosts)
    host_wildcards = [
        h.encode("utf8") if isinstance(h, str) else h for h in host_wildcards
    ]
    headers = [h.encode("utf8") if isinstance(h, str) else h for h in headers]
    methods = [h.encode("utf8") if isinstance(h, str) else h for h in methods]

    if any(h.endswith(b"/") for h in (hosts or [])) or any(
        h.endswith(b"/") for h in (host_wildcards or [])
    ):
        assert False, "Error: CORS origin rules should never end in a /"

    def _asgi_cors_decorator(app):
        @wraps(app)
        async def app_wrapped_with_cors(scope, receive, send):
            async def wrapped_send(event):
                if event["type"] == "http.response.start":
                    original_headers = event.get("headers") or []
                    access_control_allow_origin = None
                    if allow_all:
                        access_control_allow_origin = b"*"
                    elif hosts or host_wildcards or callback:
                        incoming_origin = dict(scope.get("headers") or []).get(
                            b"origin"
                        )
                        if incoming_origin:
                            matches_hosts = incoming_origin in hosts
                            matches_wildcards = any(
                                fnmatch.fnmatch(incoming_origin, host_wildcard)
                                for host_wildcard in host_wildcards
                            )
                            matches_callback = False
                            if callback is not None:
                                if asyncio.iscoroutinefunction(callback):
                                    matches_callback = await callback(incoming_origin)
                                else:
                                    matches_callback = callback(incoming_origin)
                            if matches_hosts or matches_wildcards or matches_callback:
                                access_control_allow_origin = incoming_origin

                    if access_control_allow_origin is not None:
                        # Construct a new event with new headers
                        new_headers = [
                            p
                            for p in original_headers
                            if p[0]
                            not in (
                                b"access-control-allow-origin"
                                b"access-control-allow-headers"
                                b"access-control-allow-methods",
                                b"access-control-max-age",
                            )
                        ]
                        if access_control_allow_origin:
                            new_headers.append(
                                [
                                    b"access-control-allow-origin",
                                    access_control_allow_origin,
                                ]
                            )
                        if headers:
                            new_headers.append(
                                [
                                    b"access-control-allow-headers",
                                    b", ".join(
                                        h.encode("utf-8") if isinstance(h, str) else h
                                        for h in headers
                                    ),
                                ]
                            )
                        if methods:
                            new_headers.append(
                                [
                                    b"access-control-allow-methods",
                                    b", ".join(
                                        m.encode("utf-8") if isinstance(m, str) else m
                                        for m in methods
                                    ),
                                ]
                            )
                        if max_age:
                            new_headers.append(
                                [b"access-control-max-age", str(max_age)]
                            )
                        event = {
                            "type": "http.response.start",
                            "status": event["status"],
                            "headers": new_headers,
                        }
                await send(event)

            await app(scope, receive, wrapped_send)

        return app_wrapped_with_cors

    return _asgi_cors_decorator


def asgi_cors(
    app,
    allow_all=False,
    hosts=None,
    host_wildcards=None,
    callback=None,
    headers=None,
    methods=None,
    max_age=None,
):
    return asgi_cors_decorator(
        allow_all, hosts, host_wildcards, callback, headers, methods, max_age
    )(app)


---
datasette_cors.py
---
from asgi_cors import asgi_cors
from datasette import hookimpl


@hookimpl
def asgi_wrapper(datasette):
    config = datasette.plugin_config("datasette-cors") or {}
    allow_all = config.get("allow_all") or False
    hosts = config.get("hosts") or []
    host_wildcards = config.get("host_wildcards") or []

    def wrap_with_asgi_cors(app):
        if not (hosts or host_wildcards or allow_all):
            return app

        return asgi_cors(
            app,
            allow_all=allow_all,
            hosts=hosts,
            host_wildcards=host_wildcards,
        )

    return wrap_with_asgi_cors


---

System:

Output a new datasette_cors.py plugin that adds headers and methods and max_age config options

Response:

Here's an updated datasette_cors.py plugin that adds support for the headers, methods and max_age configuration options:

from asgi_cors import asgi_cors
from datasette import hookimpl


@hookimpl
def asgi_wrapper(datasette):
    config = datasette.plugin_config("datasette-cors") or {}
    allow_all = config.get("allow_all") or False
    hosts = config.get("hosts") or []
    host_wildcards = config.get("host_wildcards") or []
    headers = config.get("headers") or []
    methods = config.get("methods") or []
    max_age = config.get("max_age")

    def wrap_with_asgi_cors(app):
        if not (hosts or host_wildcards or allow_all or headers or methods or max_age):
            return app

        return asgi_cors(
            app,
            allow_all=allow_all,
            hosts=hosts,
            host_wildcards=host_wildcards,
            headers=headers,
            methods=methods,
            max_age=max_age,
        )

    return wrap_with_asgi_cors

In this updated version:

  1. We retrieve the headers, methods, and max_age configuration options from the datasette.plugin_config("datasette-cors") dictionary.

  2. We pass the headers, methods, and max_age values to the asgi_cors function in addition to the existing allow_all, hosts, and host_wildcards options.

  3. We also check if any of the new options (headers, methods, max_age) are present before deciding whether to wrap the app with CORS headers. If none of the options are specified, we return the original app without CORS headers.

Now, users of the datasette-cors plugin can configure the allowed headers, methods, and max age for CORS by adding the corresponding options to their Datasette configuration file or by passing them as arguments when running Datasette.

For example, in the metadata.json configuration file:

{
  "plugins": {
    "datasette-cors": {
      "allow_all": true,
      "headers": ["Authorization", "Content-Type"],
      "methods": ["GET", "POST", "OPTIONS"],
      "max_age": 3600
    }
  }
}

This configuration allows all origins, sets the allowed headers to "Authorization" and "Content-Type", allows the "GET", "POST", and "OPTIONS" methods, and sets the max age to 3600 seconds (1 hour).

With these changes, the datasette-cors plugin now supports configuring headers, methods, and max age for CORS.

2024-04-08T16:43:11

Prompt:

test_datasette_cors.py
---
import httpx
import pytest
from unittest.mock import ANY
from datasette.app import Datasette


@pytest.mark.asyncio
async def test_datasette_cors_plugin_installed():
    async with httpx.AsyncClient(app=Datasette([], memory=True).app()) as client:
        response = await client.get("http://localhost/-/plugins.json")
        assert response.status_code == 200
        assert response.json() == [
            {
                "name": "datasette-cors",
                "static": False,
                "templates": False,
                "version": ANY,
                "hooks": ["asgi_wrapper"],
            }
        ]


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "request_origin,expected_cors_header",
    [
        (None, None),
        ("http://example.com", "http://example.com"),
        ("http://foo.com", None),
    ],
)
async def test_asgi_cors_hosts(request_origin, expected_cors_header):
    headers = {}
    if request_origin:
        headers["Origin"] = request_origin

    async with httpx.AsyncClient(
        app=Datasette(
            [],
            memory=True,
            metadata={"plugins": {"datasette-cors": {"hosts": ["http://example.com"]}}},
        ).app()
    ) as client:
        response = await client.get("http://localhost/", headers=headers)
        assert response.status_code == 200
        assert (
            response.headers.get("access-control-allow-origin") == expected_cors_header
        )


---

Update these tests to exercise the new options as well

Response:

Here's an updated version of the test_datasette_cors.py file that includes tests for the new headers, methods, and max_age options:

import httpx
import pytest
from unittest.mock import ANY
from datasette.app import Datasette


@pytest.mark.asyncio
async def test_datasette_cors_plugin_installed():
    async with httpx.AsyncClient(app=Datasette([], memory=True).app()) as client:
        response = await client.get("http://localhost/-/plugins.json")
        assert response.status_code == 200
        assert response.json() == [
            {
                "name": "datasette-cors",
                "static": False,
                "templates": False,
                "version": ANY,
                "hooks": ["asgi_wrapper"],
            }
        ]


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "request_origin,expected_cors_header",
    [
        (None, None),
        ("http://example.com", "http://example.com"),
        ("http://foo.com", None),
    ],
)
async def test_asgi_cors_hosts(request_origin, expected_cors_header):
    headers = {}
    if request_origin:
        headers["Origin"] = request_origin

    async with httpx.AsyncClient(
        app=Datasette(
            [],
            memory=True,
            metadata={"plugins": {"datasette-cors": {"hosts": ["http://example.com"]}}},
        ).app()
    ) as client:
        response = await client.get("http://localhost/", headers=headers)
        assert response.status_code == 200
        assert (
            response.headers.get("access-control-allow-origin") == expected_cors_header
        )


@pytest.mark.asyncio
async def test_asgi_cors_headers():
    async with httpx.AsyncClient(
        app=Datasette(
            [],
            memory=True,
            metadata={
                "plugins": {
                    "datasette-cors": {
                        "allow_all": True,
                        "headers": ["Authorization", "Content-Type"],
                    }
                }
            },
        ).app()
    ) as client:
        response = await client.get("http://localhost/")
        assert response.status_code == 200
        assert response.headers["access-control-allow-origin"] == "*"
        assert (
            response.headers["access-control-allow-headers"]
            == "Authorization, Content-Type"
        )


@pytest.mark.asyncio
async def test_asgi_cors_methods():
    async with httpx.AsyncClient(
        app=Datasette(
            [],
            memory=True,
            metadata={
                "plugins": {
                    "datasette-cors": {
                        "allow_all": True,
                        "methods": ["GET", "POST", "OPTIONS"],
                    }
                }
            },
        ).app()
    ) as client:
        response = await client.get("http://localhost/")
        assert response.status_code == 200
        assert response.headers["access-control-allow-origin"] == "*"
        assert response.headers["access-control-allow-methods"] == "GET, POST, OPTIONS"


@pytest.mark.asyncio
async def test_asgi_cors_max_age():
    async with httpx.AsyncClient(
        app=Datasette(
            [],
            memory=True,
            metadata={"plugins": {"datasette-cors": {"allow_all": True, "max_age": 3600}}},
        ).app()
    ) as client:
        response = await client.get("http://localhost/")
        assert response.status_code == 200
        assert response.headers["access-control-allow-origin"] == "*"
        assert response.headers["access-control-max-age"] == "3600"

In this updated version:

  1. We added a new test test_asgi_cors_headers() that checks if the access-control-allow-headers header is set correctly based on the headers configuration option.

  2. We added a new test test_asgi_cors_methods() that verifies if the access-control-allow-methods header is set correctly based on the methods configuration option.

  3. We added a new test test_asgi_cors_max_age() that checks if the access-control-max-age header is set correctly based on the max_age configuration option.

These tests ensure that the new configuration options (headers, methods, and max_age) are properly applied and the corresponding CORS headers are set in the response.

You can run these tests using pytest to verify that the datasette-cors plugin is working as expected with the new configuration options.

2024-04-08T16:44:24

Prompt:

# datasette-cors

[![PyPI](https://img.shields.io/pypi/v/datasette-cors.svg)](https://pypi.org/project/datasette-cors/)
[![Tests](https://github.com/simonw/datasette-cors/actions/workflows/test.yml/badge.svg)](https://github.com/simonw/datasette-cors/actions/workflows/test.yml)
[![Changelog](https://img.shields.io/github/v/release/simonw/datasette-cors?include_prereleases&label=changelog)](https://github.com/simonw/datasette-cors/releases)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-cors/blob/main/LICENSE)

Datasette plugin for configuring CORS headers, based on https://github.com/simonw/asgi-cors

You can use this plugin to allow JavaScript running on a whitelisted set of domains to make `fetch()` calls to the JSON API provided by your Datasette instance.

## Installation

    pip install datasette-cors

## Configuration

You need to add some configuration to your Datasette `metadata.json` file for this plugin to take effect.

To whitelist specific domains, use this:

```json
{
    "plugins": {
        "datasette-cors": {
            "hosts": ["https://www.example.com"]
        }
    }
}
```

You can also whitelist patterns like this:

```json
{
    "plugins": {
        "datasette-cors": {
            "host_wildcards": ["https://*.example.com"]
        }
    }
}
```

## Testing it

To test this plugin out, run it locally by saving one of the above examples as `metadata.json` and running this:

    $ datasette --memory -m metadata.json

Now visit https://www.example.com/ in your browser, open the browser developer console and paste in the following:

```javascript
fetch("http://127.0.0.1:8001/:memory:.json?sql=select+sqlite_version%28%29").then(r => r.json()).then(console.log)
```

If the plugin is running correctly, you will see the JSON response output to the console.

Update the README to document the new config options

Response:

Here's an updated version of the README that includes documentation for the new configuration options:

# datasette-cors

[![PyPI](https://img.shields.io/pypi/v/datasette-cors.svg)](https://pypi.org/project/datasette-cors/)
[![Tests](https://github.com/simonw/datasette-cors/actions/workflows/test.yml/badge.svg)](https://github.com/simonw/datasette-cors/actions/workflows/test.yml)
[![Changelog](https://img.shields.io/github/v/release/simonw/datasette-cors?include_prereleases&label=changelog)](https://github.com/simonw/datasette-cors/releases)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-cors/blob/main/LICENSE)

Datasette plugin for configuring CORS headers, based on https://github.com/simonw/asgi-cors

You can use this plugin to allow JavaScript running on a whitelisted set of domains to make `fetch()` calls to the JSON API provided by your Datasette instance.

## Installation

    pip install datasette-cors

## Configuration

You need to add some configuration to your Datasette `metadata.json` file for this plugin to take effect.

To whitelist specific domains, use this:

```json
{
    "plugins": {
        "datasette-cors": {
            "hosts": ["https://www.example.com"]
        }
    }
}
```

You can also whitelist patterns like this:

```json
{
    "plugins": {
        "datasette-cors": {
            "host_wildcards": ["https://*.example.com"]
        }
    }
}
```

To allow all origins, use:

```json
{
    "plugins": {
        "datasette-cors": {
            "allow_all": true
        }
    }
}
```

You can specify allowed headers using the `headers` option:

```json
{
    "plugins": {
        "datasette-cors": {
            "allow_all": true,
            "headers": ["Authorization", "Content-Type"]
        }
    }
}
```

To allow specific HTTP methods, use the `methods` option:

```json
{
    "plugins": {
        "datasette-cors": {
            "allow_all": true,
            "methods": ["GET", "POST", "OPTIONS"]
        }
    }
}
```

You can set the `Access-Control-Max-Age` header using the `max_age` option:

```json
{
    "plugins": {
        "datasette-cors": {
            "allow_all": true,
            "max_age": 3600
        }
    }
}
```

## Testing it

To test this plugin out, run it locally by saving one of the above examples as `metadata.json` and running this:

    $ datasette --memory -m metadata.json

Now visit https://www.example.com/ in your browser, open the browser developer console and paste in the following:

```javascript
fetch("http://127.0.0.1:8001/:memory:.json?sql=select+sqlite_version%28%29").then(r => r.json()).then(console.log)
```

If the plugin is running correctly, you will see the JSON response output to the console.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment