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 ### 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. 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. 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 #### Administration endpoints
Operations on the endpoints described in this section require an administrator token. 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 If desired, use `dump-things-upload-config` to read the configuration from a file and
generate respective POST-requests. `dump-things-load-config` can also be used to 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. generate a configuration from an old, i.e. dump-things version < 6, configuration file.
##### Collections ##### Collections
- `POST /collections`: create a new collection from the posted configuration object. - `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` 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. - `GET /collections`: get information about the currently existing collections.

View file

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

View file

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

View file

@ -77,6 +77,30 @@ async def create_collection(
body: CollectionRequest, body: CollectionRequest,
api_key: str = Depends(api_key_header_scheme), 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() instance_state = get_instance_state()
abstract_config = get_config() abstract_config = get_config()
@ -85,7 +109,7 @@ async def create_collection(
authenticate_admin(instance_state, abstract_config, api_key) authenticate_admin(instance_state, abstract_config, api_key)
# Check for existing collection name # 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( raise HTTPException(
status_code=HTTP_409_CONFLICT, status_code=HTTP_409_CONFLICT,
detail=f"Collection with name '{body.name}' already exists.", 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.", detail=f"Collection name '{body.name}' is reserved and cannot be created.",
) )
# Check for distinct directories # Check for distinct directories.
for directory in (body.incoming, body.curated): # TODO: we skip this currently because a number of version 5 installations
if directory: # deliberately put inboxes into the same path. Those configuration cannot
ensure_unique_directory( # be established if this check is performed. Instead of the `if False:`-
abstract_config, # clause, we should introduce a configuration for the server to specify
instance_state, # whether unique directories are required.
directory, 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 # Check for incoming directory if any of the tokens allows writing
validate_incoming_paths(abstract_config, body) 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 # Update the abstract configuration
abstract_config.collections[body.name] = body abstract_config.collections[body.name] = body
@ -123,8 +162,6 @@ async def create_collection(
config=abstract_config, config=abstract_config,
) )
response.headers['Location'] = f'/collections/{quote(body.name)}'
@router.get( @router.get(
'/collections', '/collections',
@ -133,14 +170,22 @@ async def create_collection(
) )
async def get_collections( async def get_collections(
api_key: str = Depends(api_key_header_scheme), api_key: str = Depends(api_key_header_scheme),
) -> dict[str, CollectionConfig]: ) -> list[CollectionRequest]:
instance_state = get_instance_state() instance_state = get_instance_state()
abstract_config = get_config() abstract_config = get_config()
# Check admin rights # Check admin rights
authenticate_admin(instance_state, abstract_config, api_key) 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( @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 from __future__ import annotations
import json
import os import os
import sys import sys
from argparse import ArgumentParser from argparse import ArgumentParser
@ -25,15 +26,25 @@ parser.add_argument(
'config_file', 'config_file',
help='The path to the 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( parser.add_argument(
'--send-to', '--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( parser.add_argument(
'--old-format', '--old-format',
action='store_true', action='store_true',
help='If provided, assume that the configuration is in the old format ' help='If provided, assume that the configuration is in version 1 format '
'and convert it to the new format internally (in old format: tokens ' 'and convert it to the new format internally (in version 1: tokens '
'had no `hashed`-attribute and no `representation`-attribute, the token ' 'had no `hashed`-attribute and no `representation`-attribute, the token '
'representation was the key of the token configuration, ' 'representation was the key of the token configuration, '
'collections had no `schema`-attribute, and `sqlite`-backends had ' 'collections had no `schema`-attribute, and `sqlite`-backends had '
@ -53,12 +64,27 @@ parser.add_argument(
def main(): def main():
arguments = parser.parse_args() arguments = parser.parse_args()
with open(arguments.config_file) as config_file: config_file_path = Path(arguments.config_file)
configuration = yaml.safe_load(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: if arguments.old_format:
configuration = convert_to_new_format(configuration, arguments.store) configuration = convert_config_1_to_config_2(configuration, arguments.store)
else: else:
if arguments.store: if arguments.store:
print( print(
@ -68,13 +94,14 @@ def main():
flush=True, flush=True,
) )
assert configuration['type'] == 'collections', '`type: collections` missing in config-file'
assert configuration['version'] == 2, '`version: 2` missing in config-file' assert configuration['version'] == 2, '`version: 2` missing in config-file'
if arguments.send_to: if arguments.send_to:
admin_token = os.environ.get('DTS_ADMIN_TOKEN') admin_token = os.environ.get('DTS_ADMIN_TOKEN')
if not admin_token: if not admin_token:
print( 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, file=sys.stderr,
flush=True, flush=True,
) )
@ -93,23 +120,34 @@ def main():
print(f'{rte.args[0]}', file=sys.stderr, flush=True) print(f'{rte.args[0]}', file=sys.stderr, flush=True)
return 2 return 2
print( if file_type == 'json':
yaml.dump( print(json.dumps(configuration, indent=2, sort_keys=False))
data=configuration, elif file_type == 'yaml':
sort_keys=False, print(
allow_unicode=True, yaml.dump(
default_flow_style=False, data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
) )
)
return 0 return 0
def convert_to_new_format( def convert_config_1_to_config_2(
old_configuration: dict, old_configuration: dict,
store_path: str | Path, store_path: str | Path,
) -> dict: ) -> 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) counter = count(1)
new_tokens_dict = { new_tokens_dict = {
@ -126,7 +164,7 @@ def convert_to_new_format(
for token_name, token_config in new_tokens_dict.items() 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(): for collection_name, collection_config in old_configuration['collections'].items():
backend = collection_config.get('backend') backend = collection_config.get('backend')
if backend and backend['type'].startswith('sqlite'): 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']] collection_config['default_token'] = old_to_new_token_mapping[collection_config['default_token']]
new_configuration = { new_configuration = {
'type': old_configuration['type'], 'type': 'collections',
'version': '2', 'version': 2,
'tokens': new_tokens_dict, 'tokens': new_tokens_dict,
'collections': old_configuration['collections'], 'collections': old_configuration['collections'],
'admin_tokens': {}, 'admin_tokens': {},
@ -226,7 +264,7 @@ def _post_data(
content_class: str, content_class: str,
content_name: 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: if result.status_code >= 300:
msg = f'Error uploading {content_class}: {content_name}: {result.text}' msg = f'Error uploading {content_class}: {content_name}: {result.text}'
raise RuntimeError(msg) raise RuntimeError(msg)

View file

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

View file

@ -47,9 +47,8 @@ class _ModelStore:
obj: BaseModel, obj: BaseModel,
submitter: str, submitter: str,
) -> Iterable[tuple[str, dict]]: ) -> Iterable[tuple[str, dict]]:
if obj.__class__.__name__ == 'Thing': if obj.__class__.__name__ == 'Thing' and 'dlthings:placeholder' in (obj.annotations or dict()):
msg = f'Cannot store `Thing` instance: {obj}.' return []
raise ValueError(msg)
# Extract inlined records from the object, store individual records # Extract inlined records from the object, store individual records
# and return the list of stored records. # and return the list of stored records.
@ -142,17 +141,30 @@ class _ModelStore:
*[ *[
self.extract_inlined(sub_record) self.extract_inlined(sub_record)
for sub_record in record.relations.values() 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. # 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 # 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 = record.model_copy()
new_record.relations = { 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 for sub_record_pid in record.relations
} }
return [new_record, *extracted_sub_records] 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 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): def test_funky_pid(fastapi_client_simple):
test_client, _, _ = fastapi_client_simple test_client, _, _ = fastapi_client_simple
record_pid = 'dlflatsocial:contributors/someone' record_pid = 'dlflatsocial:contributors/someone'

View file

@ -12,6 +12,7 @@ from dump_things_service import (
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
) )
from dump_things_service.abstract_config import ( from dump_things_service.abstract_config import (
GitAuditBackendConfig,
TokenCollectionConfig, TokenCollectionConfig,
TokenModes, TokenModes,
hash_token_representation, 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) 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): def test_collection_reading(fastapi_client_simple):
test_client, _, admin_token = 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 assert response.status_code == HTTP_200_OK
response_object = response.json() response_object = response.json()
assert isinstance(response_object, dict) assert isinstance(response_object, list)
assert len(response_object) == 10 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}, headers={'x-dumpthings-token': plain_new_admin_token},
) )
assert response.status_code == HTTP_200_OK 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 # Delete the new admin token
response = test_client.delete( 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 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): def test_collection_dir_reuse_detection(fastapi_client_simple):
test_client, _, admin_token = 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: class Thing:
pid: str pid: str
relations: dict[str, Thing] | None = None relations: dict[str, Thing] | None = None
annotations: dict[str, str] | None = None
def model_copy(self): def model_copy(self):
return copy(self) return copy(self)
@ -87,9 +88,24 @@ empty_inlined_object = Person(
pid='dlflatsocial:test_extract_a', pid='dlflatsocial:test_extract_a',
given_name='Opa', given_name='Opa',
relations={ relations={
'dlflatsocial:test_extract_a_a': Thing(pid='dlflatsocial:test_extract_a_a'), 'dlflatsocial:test_extract_a_a': Thing(
'dlflatsocial:test_extract_a_b': Thing(pid='dlflatsocial:test_extract_a_b'), pid='dlflatsocial:test_extract_a_a',
'dlflatsocial:test_extract_a_c': Thing(pid='dlflatsocial:test_extract_a_c'), 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, json=empty_inlined_json_record,
) )
assert response.status_code == HTTP_200_OK 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 logging
import random import random
import re import re
from os import name
from urllib.parse import quote from urllib.parse import quote
from fastapi import ( from fastapi import (
@ -76,13 +77,42 @@ async def create_token(
api_key: str = Depends(api_key_header_scheme), api_key: str = Depends(api_key_header_scheme),
) -> TokenRequest: ) -> 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() instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path) abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key) authenticate_admin(instance_state, abstract_config, api_key)
# Check for existing token-name # 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( raise HTTPException(
status_code=HTTP_409_CONFLICT, status_code=HTTP_409_CONFLICT,
detail=f"Token with name '{body.name}' already exists.", detail=f"Token with name '{body.name}' already exists.",
@ -159,7 +189,6 @@ async def create_token(
config=abstract_config, config=abstract_config,
) )
response.headers['Location'] = f'/tokens/{quote(body.name)}'
return TokenRequest( return TokenRequest(
name=body.name, name=body.name,
user_id=body.user_id, user_id=body.user_id,
@ -265,11 +294,34 @@ async def create_admin_token(
body: AdminTokenRequest, body: AdminTokenRequest,
api_key: str = Depends(api_key_header_scheme), 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 # Check for token content
if not body.representation: if not body.representation:
@ -280,12 +332,20 @@ async def create_admin_token(
detail='Hashed token is not a 64-digits hex-number' detail='Hashed token is not a 64-digits hex-number'
raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=detail) 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 # Check for existing token-name
if body.name in abstract_config.admin_tokens: if body.name in abstract_config.admin_tokens:
raise HTTPException( if allow_replace:
status_code=HTTP_409_CONFLICT, del abstract_config.admin_tokens[body.name]
detail=f"Admin token with name '{body.name}' already exists.", 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 # It is sufficient to add the new admin token to the admin_token dictionary
# in order to manifest the new configuration. # in order to manifest the new configuration.
@ -307,12 +367,28 @@ async def create_admin_token(
) )
async def get_admin_token( async def get_admin_token(
api_key: str = Depends(api_key_header_scheme), api_key: str = Depends(api_key_header_scheme),
) -> list[str]: ) -> list[dict]:
instance_state = get_instance_state() instance_state = get_instance_state()
abstract_config = read_config(store_path=instance_state.store_path) abstract_config = read_config(store_path=instance_state.store_path)
authenticate_admin(instance_state, abstract_config, api_key) 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) + ( return list(abstract_config.admin_tokens) + (
[] []
if instance_state.bootstrap_token is None 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-copy-store = "dump_things_service.commands.copy_store:main"
dump-things-pid-check = "dump_things_service.commands.check_pids: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-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-report = "dump_things_service.commands.gitaudit_report:main"
dump-things-gitaudit-rebuild-index = "dump_things_service.commands.gitaudit_rebuild_index: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" dump-things-hash-token = "dump_things_service.commands.hash_token:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]