Model: claude-3-opus-20240229
../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
---
Output a new datasette_cors.py plugin that adds headers and methods and max_age config options
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:
-
We retrieve the
headers
,methods
, andmax_age
configuration options from thedatasette.plugin_config("datasette-cors")
dictionary. -
We pass the
headers
,methods
, andmax_age
values to theasgi_cors
function in addition to the existingallow_all
,hosts
, andhost_wildcards
options. -
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.
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
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:
-
We added a new test
test_asgi_cors_headers()
that checks if theaccess-control-allow-headers
header is set correctly based on theheaders
configuration option. -
We added a new test
test_asgi_cors_methods()
that verifies if theaccess-control-allow-methods
header is set correctly based on themethods
configuration option. -
We added a new test
test_asgi_cors_max_age()
that checks if theaccess-control-max-age
header is set correctly based on themax_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.
# 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
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.