Support configuration update via PUT method #220

Merged
cmo merged 11 commits from improve-load-config into master 2026-06-24 14:34:16 +00:00
14 changed files with 568 additions and 86 deletions

View file

@ -71,7 +71,7 @@ The above command runs the service on the network location `127.0.0.1:8000` and
### Configuration file
The service provides the tool `dump-things-load-config` which can load configurations from a file and manifest those configurations on a running service via the administration endpoints.
The service provides the tool `dump-things-upload-config` which can load configurations from a file and manifest those configurations on a running service via the administration endpoints.
A configuration defines collections, paths for incoming and curated data for each collection, as well as token properties.
Token properties include a submitter identification and for each collection an incoming zone specifier, permissions for reading and writing of the incoming zone and permission for reading the curated data of the collection.
@ -733,15 +733,15 @@ Details about the curation endpoints can be found in [this issue](https://codebe
#### Administration endpoints
Operations on the endpoints described in this section require an administrator token.
If desired, use `dump-things-load-config` to read the configuration from a file and
generate respective POST-requests. `dump-things-load-config` can also be used to
If desired, use `dump-things-upload-config` to read the configuration from a file and
generate respective POST-requests. `dump-things-upload-config` can also be used to
generate a configuration from an old, i.e. dump-things version < 6, configuration file.
##### Collections
- `POST /collections`: create a new collection from the posted configuration object.
For a specification of the configuration object see the object `CollectionRequest` in the file `dump_things_service/collection_endpoints.py`
(Use `dump-things-load-config` to read the configuration from a file and generate respective POST-requests)
(Use `dump-things-upload-config` to read the configuration from a file and generate respective POST-requests)
- `GET /collections`: get information about the currently existing collections.

View file

@ -159,7 +159,7 @@ class AdminTokenConfig(StrictModel):
class Configuration(StrictModel):
type: str = Literal['collections']
version: str = Literal['2']
version: int = Literal[2]
collections: dict[str, CollectionConfig] = {}
tokens: dict[str, TokenConfig] = {}
admin_tokens: dict[str, AdminTokenConfig] = {}
@ -244,7 +244,7 @@ def read_config(
if record_info
else Configuration(
type='collections',
version = '2',
version = 2,
)
)
except ValidationError as ve:

View file

@ -221,7 +221,6 @@ def create_collection(
active_classes &= set(collection_configuration.use_classes)
if collection_configuration.ignore_classes:
active_classes -= set(collection_configuration.ignore_classes)
active_classes -= {'Thing'}
instance_state.collections[collection_name] = InstanceStateCollectionInfo(
active_classes=active_classes,
tag_info=dict(),

View file

@ -77,6 +77,30 @@ async def create_collection(
body: CollectionRequest,
api_key: str = Depends(api_key_header_scheme),
):
await create_or_replace_collection(body, api_key, allow_replace=False)
response.headers['Location'] = f'/collections/{quote(body.name)}'
@router.put(
'/collections',
tags=['Administration interface'],
name='Create a new collection',
status_code=HTTP_201_CREATED,
)
async def replace_collection(
response: Response,
body: CollectionRequest,
api_key: str = Depends(api_key_header_scheme),
):
await create_or_replace_collection(body, api_key, allow_replace=True)
response.headers['Location'] = f'/collections/{quote(body.name)}'
async def create_or_replace_collection(
body: CollectionRequest,
api_key: str,
allow_replace: bool,
):
instance_state = get_instance_state()
abstract_config = get_config()
@ -85,7 +109,7 @@ async def create_collection(
authenticate_admin(instance_state, abstract_config, api_key)
# Check for existing collection name
if body.name in abstract_config.collections:
if body.name in abstract_config.collections and not allow_replace:
raise HTTPException(
status_code=HTTP_409_CONFLICT,
detail=f"Collection with name '{body.name}' already exists.",
@ -98,18 +122,33 @@ async def create_collection(
detail=f"Collection name '{body.name}' is reserved and cannot be created.",
)
# Check for distinct directories
for directory in (body.incoming, body.curated):
if directory:
ensure_unique_directory(
abstract_config,
instance_state,
directory,
)
# Check for distinct directories.
# TODO: we skip this currently because a number of version 5 installations
# deliberately put inboxes into the same path. Those configuration cannot
# be established if this check is performed. Instead of the `if False:`-
# clause, we should introduce a configuration for the server to specify
# whether unique directories are required.
if False:
for directory in (body.incoming, body.curated):
if directory:
ensure_unique_directory(
abstract_config,
instance_state,
directory,
)
# Check for incoming directory if any of the tokens allows writing
validate_incoming_paths(abstract_config, body)
# If the configuration already exist, we have to delete it here and
# manifest the reduced configuration. This ensures that the new collection
# is fully manifested later
if body.name in abstract_config.collections:
del abstract_config.collections[body.name]
# Manifest the abstract configuration
with wrap_http_exception(ConfigError):
manifest_configuration(abstract_config, instance_state)
# Update the abstract configuration
abstract_config.collections[body.name] = body
@ -123,8 +162,6 @@ async def create_collection(
config=abstract_config,
)
response.headers['Location'] = f'/collections/{quote(body.name)}'
@router.get(
'/collections',
@ -133,14 +170,22 @@ async def create_collection(
)
async def get_collections(
api_key: str = Depends(api_key_header_scheme),
) -> dict[str, CollectionConfig]:
) -> list[CollectionRequest]:
instance_state = get_instance_state()
abstract_config = get_config()
# Check admin rights
authenticate_admin(instance_state, abstract_config, api_key)
return abstract_config.collections
return [
CollectionRequest(
**{
'name': collection_name,
**collection_info.model_dump(mode='json', by_alias=True)
}
)
for collection_name, collection_info in abstract_config.collections.items()
]
@router.get(

View file

@ -0,0 +1,176 @@
from __future__ import annotations
import json
import os
import sys
from argparse import ArgumentParser
from itertools import count
from pathlib import Path
import requests
import yaml
parser = ArgumentParser(
prog='Download a complete configuration of a running service',
description='Read a configuration from dump-things endpoints and create a '
'configuration-file that can be possibly modified and uploaded '
'to a running service by dump-things-upload-config.'
' '
'An admin token has to be provided in the environment variable '
'`DTS_ADMIN_TOKEN`.',
)
parser.add_argument(
'server_api',
help='The base URL of the server API.',
)
parser.add_argument(
'--entities', '-e',
action='append',
choices=['admin_tokens', 'collections', 'tokens'],
help='Specify for which entities the configuration should be downloaded. '
' Possible values are `admin_tokens`, `collections`, or `tokens` '
'(repeat to download configuration for more than one entity). If this '
'option is not provided, configurations for all entities will be '
'downloaded.'
)
parser.add_argument(
'--format', '-f',
nargs='?',
default='yaml',
choices=['json', 'yaml'],
help='Specify the format of the output. Possible values are `json` '
'and `yaml` (the default is `yaml`).'
)
def main():
arguments = parser.parse_args()
entities = (
arguments.entities
if arguments.entities is not None
else ['admin_tokens', 'collections', 'tokens']
)
admin_token = os.environ.get('DTS_ADMIN_TOKEN')
if not admin_token:
print(
'An admin token must be provided in the environment variable `DTS_ADMIN_TOKEN`',
file=sys.stderr,
flush=True,
)
return 1
configuration = get_configuration(
arguments.server_api,
admin_token,
entities,
)
if arguments.format == 'json':
print(json.dumps(configuration, indent=2, sort_keys=False))
elif arguments.format == 'yaml':
print(
yaml.dump(
data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
)
return 0
def get_configuration(
api_url: str,
admin_token: str,
entities: list[str],
) -> dict:
result = {}
if 'collections' in entities:
result['collections'] = get_collections(api_url, admin_token)
if 'tokens' in entities:
result['tokens'] = get_tokens(api_url, admin_token)
if 'admin_tokens' in entities:
result['admin_tokens'] = get_admin_tokens(api_url, admin_token)
return {
'type': 'collections',
'version': 2,
**result,
}
def list_to_dict_on_key(
elements: list[dict],
extract_key: str,
) -> dict:
return {
element[extract_key]: {
element_key: value for element_key, value in element.items()
if element_key != extract_key
}
for element in elements
}
def get_tokens(
api_url: str,
admin_token: str,
) -> dict:
token_list = _get_data(
url=api_url + '/tokens',
token=admin_token,
content_class='tokens',
)
return list_to_dict_on_key(token_list, extract_key='name')
def get_collections(
api_url: str,
admin_token: str,
) -> dict:
collection_list = _get_data(
url=api_url + '/collections',
token=admin_token,
content_class='collections',
)
return list_to_dict_on_key(collection_list, extract_key='name')
def get_admin_tokens(
api_url: str,
admin_token: str,
) -> dict:
admin_token_list = _get_data(
url=api_url + '/admin_tokens',
token=admin_token,
content_class='admin tokens',
)
cleaned_admin_token_list = [
list_entry
for list_entry in admin_token_list
if list_entry['name'] != '__bootstrap__'
]
return list_to_dict_on_key(cleaned_admin_token_list, extract_key='name')
def _get_data(
url: str,
token: str,
content_class: str,
) -> list:
result = requests.get(url, headers={'x-dumpthings-token': token})
if result.status_code >= 300:
msg = f'Error downloading {content_class}: {result.text}'
raise RuntimeError(msg)
return result.json()
if __name__ == '__main__':
sys.exit(main())

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import os
import sys
from argparse import ArgumentParser
@ -25,15 +26,25 @@ parser.add_argument(
'config_file',
help='The path to the config file',
)
parser.add_argument(
'--format', '-f',
nargs='?',
choices=['json', 'yaml'],
help='Specify the format of the input file. Possible values are `json` '
'and `yaml`. If this option is given, the '
'suffix of the configuration file is ignored.'
)
parser.add_argument(
'--send-to',
help='The base URL of the server API',
help='The base URL of the server API. If this option is provided, the '
'configuration will be sent to the server API, otherwise it will just '
'be written to stdout.',
)
parser.add_argument(
'--old-format',
action='store_true',
help='If provided, assume that the configuration is in the old format '
'and convert it to the new format internally (in old format: tokens '
help='If provided, assume that the configuration is in version 1 format '
'and convert it to the new format internally (in version 1: tokens '
'had no `hashed`-attribute and no `representation`-attribute, the token '
'representation was the key of the token configuration, '
'collections had no `schema`-attribute, and `sqlite`-backends had '
@ -53,12 +64,27 @@ parser.add_argument(
def main():
arguments = parser.parse_args()
with open(arguments.config_file) as config_file:
configuration = yaml.safe_load(config_file)
config_file_path = Path(arguments.config_file)
with config_file_path.open('rt') as config_file:
file_type = (
arguments.format
if arguments.format is not None
else config_file_path.suffix[1:]
)
if file_type == 'json':
configuration = json.load(config_file)
elif file_type == 'yaml':
configuration = yaml.safe_load(config_file)
else:
print(
f'Unknown file type {config_file_path} (use `.json` or `.yaml` suffix, or specify the format with --format <json|yaml>)',
file=sys.stderr,
flush=True,
)
return 1
assert configuration['type'] == 'collections', '`type`-entry missing in old config-file'
if arguments.old_format:
configuration = convert_to_new_format(configuration, arguments.store)
configuration = convert_config_1_to_config_2(configuration, arguments.store)
else:
if arguments.store:
print(
@ -68,13 +94,14 @@ def main():
flush=True,
)
assert configuration['type'] == 'collections', '`type: collections` missing in config-file'
assert configuration['version'] == 2, '`version: 2` missing in config-file'
if arguments.send_to:
admin_token = os.environ.get('DTS_ADMIN_TOKEN')
if not admin_token:
print(
'An admin token not provided in the environment variable `DTS_ADMIN_TOKEN`',
'An admin token must be provided in the environment variable `DTS_ADMIN_TOKEN`',
file=sys.stderr,
flush=True,
)
@ -93,23 +120,34 @@ def main():
print(f'{rte.args[0]}', file=sys.stderr, flush=True)
return 2
print(
yaml.dump(
data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
if file_type == 'json':
print(json.dumps(configuration, indent=2, sort_keys=False))
elif file_type == 'yaml':
print(
yaml.dump(
data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
)
)
return 0
def convert_to_new_format(
def convert_config_1_to_config_2(
old_configuration: dict,
store_path: str | Path,
) -> dict:
assert old_configuration['version'] == 1, '`version: 1` missing in old config-file'
old_version = old_configuration.get('version')
if old_version != 1:
msg = f'`Unknown old configuration format: {old_version}'
raise ValueError(msg)
config_type = old_configuration.get('type')
if config_type != 'collections':
msg = f'Unknown type in config-file: {config_type}'
raise ValueError(msg)
counter = count(1)
new_tokens_dict = {
@ -126,7 +164,7 @@ def convert_to_new_format(
for token_name, token_config in new_tokens_dict.items()
}
store_path = Path(store_path)
store_path = Path(store_path) if store_path else None
for collection_name, collection_config in old_configuration['collections'].items():
backend = collection_config.get('backend')
if backend and backend['type'].startswith('sqlite'):
@ -146,8 +184,8 @@ def convert_to_new_format(
collection_config['default_token'] = old_to_new_token_mapping[collection_config['default_token']]
new_configuration = {
'type': old_configuration['type'],
'version': '2',
'type': 'collections',
'version': 2,
'tokens': new_tokens_dict,
'collections': old_configuration['collections'],
'admin_tokens': {},
@ -226,7 +264,7 @@ def _post_data(
content_class: str,
content_name: str,
):
result = requests.post(url, headers={'x-dumpthings-token': token}, json=data,)
result = requests.put(url, headers={'x-dumpthings-token': token}, json=data,)
if result.status_code >= 300:
msg = f'Error uploading {content_class}: {content_name}: {result.text}'
raise RuntimeError(msg)

View file

@ -8,7 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from dump_things_service.abstract_config import store_config
from dump_things_service.commands.load_config import convert_to_new_format
from dump_things_service.commands.upload_config import convert_config_1_to_config_2
from dump_things_service.manifest import manifest_configuration
# Perform the patching before importing any third-party libraries
from dump_things_service.patches import enabled # noqa F401 -- used by generated code
@ -259,7 +259,7 @@ def initialize_from_config_file(
'Converting version 1 configuration at %s',
arguments.config,
)
config_dict = convert_to_new_format(
config_dict = convert_config_1_to_config_2(
config_dict,
instance_state.store_path,
)

View file

@ -47,9 +47,8 @@ class _ModelStore:
obj: BaseModel,
submitter: str,
) -> Iterable[tuple[str, dict]]:
if obj.__class__.__name__ == 'Thing':
msg = f'Cannot store `Thing` instance: {obj}.'
raise ValueError(msg)
if obj.__class__.__name__ == 'Thing' and 'dlthings:placeholder' in (obj.annotations or dict()):
return []
# Extract inlined records from the object, store individual records
# and return the list of stored records.
@ -142,17 +141,30 @@ class _ModelStore:
*[
self.extract_inlined(sub_record)
for sub_record in record.relations.values()
# Do not extract 'empty'-Thing records, those are just
# Do not extract 'empty'-Thing records with an
# `dlthings:placeholder` annotation. These records are just
# placeholders for already extracted records.
if sub_record != self.model.Thing(pid=sub_record.pid)
if sub_record != self.model.Thing(
pid=sub_record.pid,
annotations={
'dlthings:placeholder': sub_record.pid,
},
)
]
)
)
# Simplify the relations in this record. We use "empty" Thing objects
# as placeholders for extracted records.
# with a special annotation as placeholders for extracted records.
# Thing objects with this placeholder-annotation will never be stored
# individually.
new_record = record.model_copy()
new_record.relations = {
sub_record_pid: self.model.Thing(pid=sub_record_pid)
sub_record_pid: self.model.Thing(
pid=sub_record_pid,
annotations={
'dlthings:placeholder': sub_record_pid,
}
)
for sub_record_pid in record.relations
}
return [new_record, *extracted_sub_records]

View file

@ -282,26 +282,6 @@ def test_global_store_write_fails(fastapi_client_simple):
assert response.status_code == HTTP_403_FORBIDDEN
@pytest.mark.skip(reason='No runtime store adding yet')
def test_token_store_adding(fastapi_client_simple):
test_client, store_dir = fastapi_client_simple
response = test_client.post(
'/collection_1/record/Person',
headers={'x-dumpthings-token': 'david_bowie'},
json={'pid': extra_record['pid']},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
# Create collection-directory and token-directory and retry
(store_dir / 'token_stores' / 'collection_1' / 'david_bowie').mkdir()
response = test_client.post(
'/collection_1/record/Person',
headers={'x-dumpthings-token': 'david_bowie'},
json={'pid': extra_record['pid']},
)
assert response.status_code == HTTP_200_OK
def test_funky_pid(fastapi_client_simple):
test_client, _, _ = fastapi_client_simple
record_pid = 'dlflatsocial:contributors/someone'

View file

@ -12,6 +12,7 @@ from dump_things_service import (
HTTP_401_UNAUTHORIZED,
)
from dump_things_service.abstract_config import (
GitAuditBackendConfig,
TokenCollectionConfig,
TokenModes,
hash_token_representation,
@ -178,6 +179,79 @@ def test_collection_adding(fastapi_client_simple):
assert not _name_in_openapi_paths(test_client, new_collection_name)
def test_collection_putting(fastapi_client_simple, tmp_path):
test_client, _, admin_token = fastapi_client_simple
put_collection_name = 'test_put_collection'
put_collection_request_orig = CollectionRequest(
name=put_collection_name,
default_token='test_default_token',
curated=PurePosixPath(f'{curated}/put_test_collection'),
schema=test_schema_location,
incoming=PurePosixPath(f'{incoming}/put_test_collection'),
)
put_collection_request_updated = CollectionRequest(
name=put_collection_name,
default_token='test_default_token',
curated=PurePosixPath(f'{curated}/put_test_collection'),
schema=test_schema_location,
incoming=PurePosixPath(f'{incoming}/put_test_collection_updated'),
audit_backends=[
GitAuditBackendConfig(
type='gitaudit',
path=Path(tmp_path),
auto_flush_timeout=2,
)
]
)
# Check that the collection does not yet exist
response = test_client.get(
f'/collections/{put_collection_name}',
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_404_NOT_FOUND
assert not _name_in_openapi_paths(test_client, put_collection_name)
# Add the first version of the collection
response = test_client.post(
'/collections',
headers={'x-dumpthings-token': admin_token},
json=put_collection_request_orig.model_dump(mode='json', by_alias=True),
)
assert response.status_code == HTTP_201_CREATED
assert _name_in_openapi_paths(test_client, put_collection_name)
audit_files = tuple(tmp_path.iterdir())
assert len(audit_files) == 0
response = test_client.get(
f'/collections/{put_collection_name}',
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_200_OK
# Update the collection
response = test_client.put(
'/collections',
headers={'x-dumpthings-token': admin_token},
json=put_collection_request_updated.model_dump(mode='json', by_alias=True),
)
assert response.status_code == HTTP_201_CREATED
# Check that the audit backend is activated
audit_files = tuple(tmp_path.iterdir())
assert len(audit_files) > 0
# Delete the collection again because we check for a known number of
# collections in other tests.
response = test_client.delete(
f'/collections/{put_collection_name}',
headers={'x-dumpthings-token': admin_token},
)
assert response.status_code == HTTP_200_OK
def test_collection_reading(fastapi_client_simple):
test_client, _, admin_token = fastapi_client_simple
@ -188,7 +262,7 @@ def test_collection_reading(fastapi_client_simple):
)
assert response.status_code == HTTP_200_OK
response_object = response.json()
assert isinstance(response_object, dict)
assert isinstance(response_object, list)
assert len(response_object) == 10
@ -223,7 +297,8 @@ def test_admin_token_management(fastapi_client_simple):
headers={'x-dumpthings-token': plain_new_admin_token},
)
assert response.status_code == HTTP_200_OK
assert new_admin_token_name in response.json()
names = [entry['name'] for entry in response.json()]
assert new_admin_token_name in names
# Delete the new admin token
response = test_client.delete(

View file

@ -52,6 +52,7 @@ def test_illegal_collection_name_detection(fastapi_client_simple):
assert response.status_code == HTTP_409_CONFLICT
@pytest.mark.skip(reason='Reuse detection is disabled to support existing old configurations')
def test_collection_dir_reuse_detection(fastapi_client_simple):
test_client, _, admin_token = fastapi_client_simple

View file

@ -29,6 +29,7 @@ schema_path = Path(__file__).parent / 'testschema.yaml'
class Thing:
pid: str
relations: dict[str, Thing] | None = None
annotations: dict[str, str] | None = None
def model_copy(self):
return copy(self)
@ -87,9 +88,24 @@ empty_inlined_object = Person(
pid='dlflatsocial:test_extract_a',
given_name='Opa',
relations={
'dlflatsocial:test_extract_a_a': Thing(pid='dlflatsocial:test_extract_a_a'),
'dlflatsocial:test_extract_a_b': Thing(pid='dlflatsocial:test_extract_a_b'),
'dlflatsocial:test_extract_a_c': Thing(pid='dlflatsocial:test_extract_a_c'),
'dlflatsocial:test_extract_a_a': Thing(
pid='dlflatsocial:test_extract_a_a',
annotations={
'dlthings:placeholder': 'dlflatsocial:test_extract_a_a',
},
),
'dlflatsocial:test_extract_a_b': Thing(
pid='dlflatsocial:test_extract_a_b',
annotations={
'dlthings:placeholder': 'dlflatsocial:test_extract_a_b',
},
),
'dlflatsocial:test_extract_a_c': Thing(
pid='dlflatsocial:test_extract_a_c',
annotations={
'dlthings:placeholder': 'dlflatsocial:test_extract_a_c',
},
),
},
)
@ -333,3 +349,66 @@ def test_dont_extract_empty_things_on_service(fastapi_client_simple):
json=empty_inlined_json_record,
)
assert response.status_code == HTTP_200_OK
def test_store_things(fastapi_client_simple):
test_client, store, _ = fastapi_client_simple
simple_thing = {
'pid': 'http://test.simple.thing/1',
}
# Deposit JSON record
response = test_client.post(
'/collection_1/record/Thing',
headers={'x-dumpthings-token': 'token-1'},
json=simple_thing,
)
assert response.status_code == HTTP_200_OK
# Try to read it back
response = test_client.get(
f'/collection_1/record?pid={simple_thing["pid"]}',
headers={'x-dumpthings-token': 'token-1'},
)
assert response.status_code == HTTP_200_OK
assert response.json()['pid'] == 'http://test.simple.thing/1'
def test_store_complex_things(fastapi_client_simple):
test_client, store, _ = fastapi_client_simple
complex_thing = {
'pid': 'http://test.complex.thing/1',
'relations': {
'http://test.complex.thing/1.1': {
'pid': 'http://test.complex.thing/1.1',
'relations': {
'http://test.complex.thing/1.1.1': {
'pid': 'http://test.complex.thing/1.1.1',
}
}
}
}
}
# Deposit JSON record
response = test_client.post(
'/collection_1/record/Thing',
headers={'x-dumpthings-token': 'token-1'},
json=complex_thing,
)
assert response.status_code == HTTP_200_OK
assert len(response.json()) == 3
# Try to read individual extracted elements
for pid in (
'http://test.complex.thing/1',
'http://test.complex.thing/1.1',
'http://test.complex.thing/1.1.1',
):
response = test_client.get(
f'/collection_1/record?pid={pid}',
headers={'x-dumpthings-token': 'token-1'},
)
assert response.status_code == HTTP_200_OK
assert response.json()['pid'] == pid

View file

@ -1,6 +1,7 @@
import logging
import random
import re
from os import name
from urllib.parse import quote
from fastapi import (
@ -76,13 +77,42 @@ async def create_token(
api_key: str = Depends(api_key_header_scheme),
) -> TokenRequest:
token_request = create_or_replace_token(body, api_key, allow_replace=False)
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return token_request
@router.put(
'/tokens',
tags=['Administration interface'],
name='Create a new token or replace a token',
status_code=HTTP_201_CREATED,
)
async def replace_token(
response: Response,
body: TokenRequest,
api_key: str = Depends(api_key_header_scheme),
) -> TokenRequest:
token_request = create_or_replace_token(body, api_key, allow_replace=True)
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return token_request
def create_or_replace_token(
body: TokenRequest,
api_key: str,
*,
allow_replace: bool,
) -> TokenRequest:
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key)
# Check for existing token-name
if body.name in abstract_config.tokens:
if body.name in abstract_config.tokens and not allow_replace:
raise HTTPException(
status_code=HTTP_409_CONFLICT,
detail=f"Token with name '{body.name}' already exists.",
@ -159,7 +189,6 @@ async def create_token(
config=abstract_config,
)
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return TokenRequest(
name=body.name,
user_id=body.user_id,
@ -265,11 +294,34 @@ async def create_admin_token(
body: AdminTokenRequest,
api_key: str = Depends(api_key_header_scheme),
):
return create_or_replace_admin_token(body, api_key, allow_replace=False)
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key)
@router.put(
'/admin_tokens',
tags=['Administration interface'],
name='Add a new admin token or replace an existing token',
status_code=HTTP_201_CREATED,
)
async def replace_admin_token(
body: AdminTokenRequest,
api_key: str = Depends(api_key_header_scheme),
):
return create_or_replace_admin_token(body, api_key, allow_replace=True)
def create_or_replace_admin_token(
body: AdminTokenRequest,
api_key: str,
*,
allow_replace: bool,
):
# Check for conflicting token-name
if body.name == '__bootstrap__':
raise HTTPException(
status_code=HTTP_409_CONFLICT,
detail=f"The admin token name '{body.name}' is reserved and cannot be used.",
)
# Check for token content
if not body.representation:
@ -280,12 +332,20 @@ async def create_admin_token(
detail='Hashed token is not a 64-digits hex-number'
raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=detail)
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key)
# Check for existing token-name
if body.name in abstract_config.admin_tokens:
raise HTTPException(
status_code=HTTP_409_CONFLICT,
detail=f"Admin token with name '{body.name}' already exists.",
)
if allow_replace:
del abstract_config.admin_tokens[body.name]
else:
raise HTTPException(
status_code=HTTP_409_CONFLICT,
detail=f"Admin token with name '{body.name}' already exists.",
)
# It is sufficient to add the new admin token to the admin_token dictionary
# in order to manifest the new configuration.
@ -307,12 +367,28 @@ async def create_admin_token(
)
async def get_admin_token(
api_key: str = Depends(api_key_header_scheme),
) -> list[str]:
) -> list[dict]:
instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key)
return [
{
'name': token_name,
**(token_value.model_dump(mode='json', by_alias=True))
}
for token_name, token_value in abstract_config.admin_tokens.items()
] + (
[]
if instance_state.bootstrap_token is None
else [
{
'name': '__bootstrap__',
'representation': instance_state.bootstrap_token,
}
]
)
return list(abstract_config.admin_tokens) + (
[]
if instance_state.bootstrap_token is None

View file

@ -52,9 +52,10 @@ dump-things-rebuild-index = "dump_things_service.commands.rebuild_index:main"
dump-things-copy-store = "dump_things_service.commands.copy_store:main"
dump-things-pid-check = "dump_things_service.commands.check_pids:main"
dump-things-create-merged-schema = "dump_things_service.commands.create_merged_schema:main"
dump-things-download-config = "dump_things_service.commands.download_config:main"
dump-things-gitaudit-report = "dump_things_service.commands.gitaudit_report:main"
dump-things-gitaudit-rebuild-index = "dump_things_service.commands.gitaudit_rebuild_index:main"
dump-things-load-config = "dump_things_service.commands.load_config:main"
dump-things-upload-config = "dump_things_service.commands.upload_config:main"
dump-things-hash-token = "dump_things_service.commands.hash_token:main"
[tool.hatch.build.targets.wheel]