Support configuration update via PUT method #220
14 changed files with 568 additions and 86 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
176
dump_things_service/commands/download_config.py
Normal file
176
dump_things_service/commands/download_config.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue