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
|
### 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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
|
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)
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue