13 Commits
0.9 ... v0.10

Author SHA1 Message Date
Daniel Bauer
1bc2b60609 update readme 2024-11-01 13:12:17 +01:00
Daniel Bauer
aafd404f90 add license to pyproject.toml 2024-11-01 13:10:07 +01:00
Daniel Bauer
2014a4f024 update readme 2024-11-01 13:09:19 +01:00
Daniel Bauer
31fcf221f0 add type test 2024-11-01 13:06:22 +01:00
Daniel Bauer
bf2604e303 bump version 2024-11-01 13:03:33 +01:00
Daniel Bauer
2d3dafbacc use signposting package 2024-11-01 13:02:56 +01:00
Daniel Bauer
076273d014 fix tests cannot find main package 2024-11-01 12:50:21 +01:00
daniel
93738b046f move folder 2024-10-27 22:24:45 +01:00
daniel
a863272c7e add simple example 2024-10-27 22:21:00 +01:00
daniel
bbd07d3fab update gitignore 2024-10-27 22:20:02 +01:00
daniel
695c167d32 fix import in README.md 2024-10-27 22:17:03 +01:00
Daniel Bauer
0a31aa21ff remove debug statement 2024-08-20 16:07:16 +02:00
Daniel Bauer
aa5850b0bd Update README.md 2024-08-15 23:21:21 +02:00
16 changed files with 294 additions and 100 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ dist
.idea
build
__pycache__
*.sqlite3

View File

@@ -1,3 +1,5 @@
[![Python package](https://github.com/dnlbauer/django-signposting/actions/workflows/python-package.yml/badge.svg)](https://github.com/dnlbauer/django-signposting/actions/workflows/python-package.yml)
# FAIR signposting for Django
`django_signposting` is a Django middleware library that facilitates the addition of
@@ -5,6 +7,8 @@ FAIR signposting headers to HTTP responses.
This middleware helps in making your data more FAIR (Findable, accessible, interoperable, reuseable) by
embedding signposting headers in responses, guiding clients to relevant resources linked to the response content.
Based on the [Signposting](https://github.com/stain/signposting) library.
## Features
- Automatically adds signposting headers to HTTP responses.
- Supports multiple relation types with optional media type specification.
@@ -37,38 +41,19 @@ Here's how you can use it:
```python
from django.http import HttpResponse
from django_signposting.util import add_signposts
from django_signposting.utils import add_signposts
from signposting import Signpost, LinkRel
def my_view(request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(response,
type="https://schema.org/Dataset",
author="https://orcid.org/0000-0001-9447-460X")
return response
```
Multiple links with the same link type can be added as lists and the content type of a link
can be defined by using tuples:
```python
from django.http import HttpResponse
from django_signposting.util import add_signposts
def my_view(request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(response,
type="https://schema.org/Dataset",
author="https://orcid.org/0000-0001-9447-460X",
item=[
("https://example.com/image.png", "image/png"),
("https://example.com/download.zip", "application/zip")
])
add_signposts(
response,
Signpost(LinkRel.type, "https://schema.org/Dataset"),
Signpost(LinkRel.author, "https://orcid.org/0000-0001-9447-460X")
Signpost(LinkRel.item, "https://example.com/download.zip", "application/zip")
)
return response
```
@@ -81,9 +66,9 @@ HTTP/2 200
...
link: <https://schema.org/Dataset> ; rel="type" ,
<https://orcid.org/0000-0001-9447-460X> ; rel="author" ,
<https://example.com/image.png> ; rel="item" ; type="application/json+ld"
<https://example.com/download.zip> ; rel="item" ; type="application/zip"
```
## License
Licensed under the MIT License.
Licensed under the MIT License.

View File

@@ -1,5 +1,6 @@
from typing import Callable
from django.http import HttpRequest, HttpResponse
from signposting import Signpost
class SignpostingMiddleware:
@@ -21,20 +22,18 @@ class SignpostingMiddleware:
return response
def _add_signposts(self, response: HttpResponse, typed_links: dict[str, list[str|tuple[str, str]]]):
def _add_signposts(self, response: HttpResponse, signposts: list[Signpost]):
""" Adds signposting headers to the respones.
params:
response - the response object
typed_links - a map of relation types to a list of corresponding links. Each link can be a link or a tuple of link and media type.
signposts - a list of Signposts
"""
link_snippets = []
for relation_type in typed_links.keys():
links = typed_links.get(relation_type, [])
for link in links:
if isinstance(link, tuple) and len(link) > 1:
link_snippets.append(f'<{link[0]}> ; rel="{relation_type}" ; type="{link[1]}"')
else:
link_snippets.append(f'<{link}> ; rel="{relation_type}"')
for signpost in signposts:
link_snippets.append(f'<{signpost.target}> ; rel="{signpost.rel}"')
if signpost.type:
link_snippets[-1] += f' ; type="{signpost.type}"'
response["Link"] = " , ".join(link_snippets)

View File

@@ -1,23 +1,17 @@
from django.http import HttpResponse
from signposting import Signpost
def add_signposts(response: HttpResponse, **kwargs):
def add_signposts(response: HttpResponse, *args: Signpost):
""" Adds signposting headers to the responses.
params:
response - the response object
kwargs - a map of relation types to a list of corresponding links. Each link can be a link or a tuple of link and media type.
args - a list of signposts to add to this resposnse.
"""
if not hasattr(response, '_signposts'):
response._signposts = {}
response._signposts = []
for key in kwargs.keys():
values = kwargs[key]
if isinstance(values, str) or isinstance(values, tuple):
values = [values]
if key not in response._signposts:
response._signposts[key] = values
else:
response._signposts[key] += values
for signpost in args:
if not signpost in response._signposts:
response._signposts.append(signpost)

View File

16
example/example/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for example project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
application = get_asgi_application()

124
example/example/settings.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Django settings for example project.
Generated by 'django-admin startproject' using Django 5.1.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-0_!deorp-j4ng0d$4#jrwcbw#@#)a5qf)_wgxq%or7ic4v3euc'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_signposting.middleware.SignpostingMiddleware',
]
ROOT_URLCONF = 'example.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'example.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

24
example/example/urls.py Normal file
View File

@@ -0,0 +1,24 @@
"""
URL configuration for simple project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from . import views
urlpatterns = [
path("", views.my_view),
]

12
example/example/views.py Normal file
View File

@@ -0,0 +1,12 @@
from django.http import HttpResponse
from django_signposting.utils import add_signposts
def my_view(request):
response = HttpResponse("Hello, world!")
# Add signpostings as string
add_signposts(response,
type="https://schema.org/Dataset",
author="https://orcid.org/0000-0001-9447-460X")
return response

16
example/example/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for example project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
application = get_wsgi_application()

22
example/manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@@ -7,13 +7,15 @@ name = "django_signposting"
authors = [
{name = "Daniel Bauer", email = "github@dbauer.me"}
]
description = "Add FAIR signpostings to response headers"
version = "0.9.0"
description = "Add FAIR signpostings to response headers in Django"
version = "0.10.0"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"Django>=3.0",
"signposting>=0.9.9",
]
license = {file= "LICENSE"}
[project.urls]
Homepage = "https://github.com/dnlbauer/django-signposting"

0
tests/__init__.py Normal file
View File

View File

@@ -3,5 +3,4 @@ from django.conf import settings
def pytest_sessionstart(session):
print("test")
settings.configure()

View File

@@ -1,6 +1,7 @@
from re import A
from django.http import HttpResponse
from django_signposting.middleware import SignpostingMiddleware
from signposting import LinkRel, Signpost
def test_middleware_no_signposting():
response = HttpResponse()
@@ -14,7 +15,9 @@ def test_middleware_no_signposting():
def test_middleware_signposting():
response = HttpResponse()
response.status_code = 200
response._signposts = {"author": ["http://example.com"]}
response._signposts = [
Signpost(LinkRel.author, "http://example.com")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
@@ -24,15 +27,11 @@ def test_middleware_signposting():
def test_middleware_multiple_signposts():
response = HttpResponse()
response.status_code = 200
response._signposts = {
"author": [
"http://example.com",
"http://example2.com"
],
"cite-as": [
"http://example3.com"
]
}
response._signposts = [
Signpost(LinkRel.author, "http://example.com"),
Signpost(LinkRel.author, "http://example2.com"),
Signpost(LinkRel.cite_as, "http://example3.com"),
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
@@ -45,24 +44,34 @@ def test_middleware_multiple_signposts():
def test_middleware_signpost_with_content_type():
response = HttpResponse()
response.status_code = 200
response._signposts = {
"item": [
("http://example.com", "test/json"),
]
}
response._signposts = [
Signpost(LinkRel.item, "http://example.com", "text/json")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert response.headers["Link"] == '<http://example.com> ; rel="item" ; type="test/json"'
assert response.headers["Link"] == '<http://example.com> ; rel="item" ; type="text/json"'
def test_middleware_ignore_error_responses():
response = HttpResponse()
response.status_code = 400
response._signposts = {
"author": ["https://example.com"]
}
response._signposts = [
Signpost(LinkRel.author, "http://example.com")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert "Link" not in response.headers
def test_middleware_type_link():
response = HttpResponse()
response.status_code = 200
response._signposts = [
Signpost(LinkRel.type, "http://schema.org/Dataset")
]
middleware = SignpostingMiddleware(lambda request: response)
response = middleware(None)
assert response.headers["Link"] == '<http://schema.org/Dataset> ; rel="type"'

View File

@@ -1,47 +1,38 @@
from django.http import HttpResponse
from django_signposting.utils import add_signposts
from signposting import Signpost, LinkRel
def test_add_signpost():
response = HttpResponse()
add_signposts(response, item="http://example.com")
add_signposts(response, Signpost(LinkRel.item,"http://example.com"))
assert response._signposts["item"] == ["http://example.com"]
assert len(response._signposts) == 1
def test_add_multiple_signposts():
response = HttpResponse()
add_signposts(response,
item="http://example.com",
author=["https://example2.com", "https://example3.com"]
Signpost(LinkRel.item, "http://example.com"),
Signpost(LinkRel.author, "https://example2.com"),
Signpost(LinkRel.author, "https://example3.com"),
)
assert response._signposts == {
"item": ["http://example.com"],
"author": [
"https://example2.com",
"https://example3.com",
]
}
assert len(response._signposts) == 3
def test_add_signposts_with_content_type():
def test_add_signpost_call_multiple_times():
response = HttpResponse()
add_signposts(response,
item=("http://example.com", "text/json"),
author=["https://example2.com", "https://example3.com"]
)
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
add_signposts(response, Signpost(LinkRel.item, "http://example2.com"))
assert response._signposts == {
"item": [("http://example.com", "text/json")],
"author": [
"https://example2.com",
"https://example3.com",
]
}
assert len(response._signposts) == 2
def test_add_signposts_from_dict():
def test_add_signpost_duplicate():
response = HttpResponse()
add_signposts(response, **{"cite-as": ["https://example.com"]})
assert response._signposts["cite-as"] == ["https://example.com"]
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
add_signposts(response, Signpost(LinkRel.item, "http://example.com"))
assert len(response._signposts) == 1