Reduce audit noise #251

Merged
cmo merged 9 commits from audit-noise into master 2026-07-03 14:05:52 +00:00
15 changed files with 231 additions and 94 deletions

View file

@ -162,8 +162,8 @@ class AdminTokenConfig(StrictModel):
class Configuration(StrictModel): class Configuration(StrictModel):
type: str = Literal['collections'] type: Literal['collections']
version: int = Literal[2] version: 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] = {}
@ -267,18 +267,21 @@ def store_config(
store_path, store_path,
config: Configuration, config: Configuration,
): ):
from dump_things_service.utils import order_dict # noqa PLC0415 -- global import leads to circular imports
global g_abstract_configuration # noqa PLW0603 -- this function updates a globally referenced instance global g_abstract_configuration # noqa PLW0603 -- this function updates a globally referenced instance
config_backend, audit_backend = get_config_backends(store_path) config_backend, audit_backend = get_config_backends(store_path)
json_object = config.model_dump(mode='json', exclude_none=True, by_alias=True) json_object = config.model_dump(mode='json', exclude_none=True, by_alias=True)
json_object['pid'] = dump_things_config_iri json_object['pid'] = dump_things_config_iri
sorted_json_object = order_dict(json_object)
config_backend.add_record( config_backend.add_record(
iri=dump_things_config_iri, iri=dump_things_config_iri,
class_name='DumpThingsConfig', class_name='DumpThingsConfig',
json_object=json_object, json_object=sorted_json_object,
) )
audit_backend.add_record( audit_backend.add_record(
record=json_object, record=sorted_json_object,
committer_id='__dump_things_server__', committer_id='__dump_things_server__',
) )
g_abstract_configuration = config g_abstract_configuration = config
@ -377,7 +380,7 @@ def get_token_infos_for_collection(
yield from { yield from {
(token_name, token_config, token_collection_config) (token_name, token_config, token_collection_config)
for token_name, token_config in abstract_config.tokens.items() for token_name, token_config in abstract_config.tokens.items()
for token_collection_config in token_config.collections.get(collection_name) for token_collection_config in token_config.collections.get(collection_name, [])
if token_config is not None if token_config is not None
} }
@ -470,7 +473,7 @@ def mapping_after_last_colon(pid: str, suffix: str) -> Path:
return Path(escaped_result + '.' + suffix) return Path(escaped_result + '.' + suffix)
mapping_functions = { mapping_functions: dict[MappingMethod, Callable] = {
MappingMethod.digest_md5: partial(mapping_digest, hashlib.md5), MappingMethod.digest_md5: partial(mapping_digest, hashlib.md5),
MappingMethod.digest_md5_p3: partial(mapping_digest_p3, hashlib.md5), MappingMethod.digest_md5_p3: partial(mapping_digest_p3, hashlib.md5),
MappingMethod.digest_md5_p3_p3: partial(mapping_digest_p3_p3, hashlib.md5), MappingMethod.digest_md5_p3_p3: partial(mapping_digest_p3_p3, hashlib.md5),

View file

@ -223,14 +223,11 @@ class GitAuditBackend(AuditBackend):
author_id: str, author_id: str,
record: dict, record: dict,
) -> bool: ) -> bool:
from dump_things_service.utils import json2yaml # noqa PLC0415 -- global import leads to circular imports
existing_record = self._read_record_from_repo_path(location[1]) existing_record = self._read_record_from_repo_path(location[1])
if existing_record != record: if existing_record != record:
self.current_change_set[location[1]] = yaml.dump( self.current_change_set[location[1]] = json2yaml(record)
data=record,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
self._add_log_entry(location[2], committer_id, author_id) self._add_log_entry(location[2], committer_id, author_id)
self._add_index_entry(record_id) self._add_index_entry(record_id)
return True return True
@ -249,7 +246,7 @@ class GitAuditBackend(AuditBackend):
'author_id': author_id, 'author_id': author_id,
} }
log_content = self._read_from_repo_path(log_location).decode() log_content = self._read_from_repo_path(log_location).decode()
log_content += json.dumps(entry, ensure_ascii=False) + '\n' log_content += json.dumps(entry, ensure_ascii=False, sort_keys=True) + '\n'
self.current_change_set[log_location] = log_content self.current_change_set[log_location] = log_content
def _add_index_entry( def _add_index_entry(

View file

@ -31,7 +31,7 @@ class InvalidTokenError(AuthenticationError):
class AuthenticationInfo: class AuthenticationInfo:
token_permission: TokenPermission token_permission: TokenPermission
user_id: str user_id: str
incoming_label: str | None incoming_label: str
class AuthenticationSource(metaclass=abc.ABCMeta): class AuthenticationSource(metaclass=abc.ABCMeta):

View file

@ -61,12 +61,14 @@ class RecordDirResultList(BackendResultList):
:param path: The path where the record is stored :param path: The path where the record is stored
:return: A RecordInfo object. :return: A RecordInfo object.
""" """
from dump_things_service.utils import order_dict # noqa PLC0415 -- global import leads to circular imports
with path.open('r') as f: with path.open('r') as f:
json_object = yaml.load(f, Loader=yaml.SafeLoader) json_object = yaml.load(f, Loader=yaml.SafeLoader)
return RecordInfo( return RecordInfo(
iri=iri, iri=iri,
class_name=class_name, class_name=class_name,
json_object=json_object, json_object=order_dict(json_object),
sort_key=sort_key, sort_key=sort_key,
) )
@ -111,6 +113,8 @@ class _RecordDirStore(StorageBackend):
class_name: str, class_name: str,
json_object: dict, json_object: dict,
): ):
from dump_things_service.utils import json2yaml # noqa PLC0415 -- global import leads to circular imports
pid = json_object['pid'] pid = json_object['pid']
# Generate the class directory, apply the mapping function to the record # Generate the class directory, apply the mapping function to the record
@ -132,12 +136,7 @@ class _RecordDirStore(StorageBackend):
storage_path.parent.mkdir(parents=True, exist_ok=True) storage_path.parent.mkdir(parents=True, exist_ok=True)
# Convert the record object into a YAML object # Convert the record object into a YAML object
data = yaml.dump( data = json2yaml(json_object)
data=json_object,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
storage_path.write_text(data, encoding='utf-8') storage_path.write_text(data, encoding='utf-8')
# Add the IRI to the index. # Add the IRI to the index.
@ -219,7 +218,7 @@ class _RecordDirStore(StorageBackend):
# Ensure that there is only one store per root directory. # Ensure that there is only one store per root directory.
_existing_stores = {} _existing_stores: dict[Path, _RecordDirStore] = {}
def RecordDirStore( # noqa: N802 def RecordDirStore( # noqa: N802

View file

@ -41,6 +41,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
'IndexEntry', 'IndexEntry',
'RecordDirIndex', 'RecordDirIndex',
'index_file_name',
] ]
index_file_name = '.directory_dir_index.db' index_file_name = '.directory_dir_index.db'

View file

@ -64,6 +64,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
'SQLiteBackend', 'SQLiteBackend',
'_SQLiteBackend', '_SQLiteBackend',
'record_file_name',
] ]
logger = logging.getLogger('dump_things_service') logger = logging.getLogger('dump_things_service')
@ -305,7 +306,7 @@ class _SQLiteBackend(StorageBackend):
# Ensure that there is only one SQL-backend per database file. # Ensure that there is only one SQL-backend per database file.
_existing_sqlite_backends = {} _existing_sqlite_backends: dict[Path, _SQLiteBackend] = {}
def SQLiteBackend( # noqa: N802 def SQLiteBackend( # noqa: N802

View file

@ -80,6 +80,7 @@ from dump_things_service.utils import (
create_store, create_store,
get_token_store, get_token_store,
join_default_token_permissions, join_default_token_permissions,
order_dict,
var_escape, var_escape,
wrap_http_exception, wrap_http_exception,
) )
@ -665,9 +666,10 @@ def store_record(
): ):
instance_state.validators[collection].validate(record) instance_state.validators[collection].validate(record)
sorted_record = order_dict(record)
with wrap_http_exception(CurieResolutionError): with wrap_http_exception(CurieResolutionError):
stored_records = store.store_object( stored_records = store.store_object(
obj=record, obj=sorted_record,
submitter=user_id if add_submission_tag else None, submitter=user_id if add_submission_tag else None,
) )
@ -690,4 +692,4 @@ def store_record(
), ),
media_type='text/turtle', media_type='text/turtle',
) )
return JSONResponse([record for _, record in stored_records]) return JSONResponse([order_dict(record) for _, record in stored_records])

View file

@ -6,9 +6,10 @@ import sys
from argparse import ArgumentParser from argparse import ArgumentParser
import requests import requests
import yaml
from starlette.status import HTTP_300_MULTIPLE_CHOICES from starlette.status import HTTP_300_MULTIPLE_CHOICES
from dump_things_service.utils import json2yaml
parser = ArgumentParser( parser = ArgumentParser(
prog='Download a complete configuration of a running service', prog='Download a complete configuration of a running service',
description='Read a configuration from dump-things endpoints and create a ' description='Read a configuration from dump-things endpoints and create a '
@ -76,14 +77,7 @@ def main():
if arguments.format == 'json': if arguments.format == 'json':
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
elif arguments.format == 'yaml': elif arguments.format == 'yaml':
print( # noqa T201 -- cli result output print(json2yaml(configuration)) # noqa T201 -- cli result output
yaml.dump(
data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
)
return 0 return 0

View file

@ -12,6 +12,7 @@ import yaml
from starlette.status import HTTP_300_MULTIPLE_CHOICES from starlette.status import HTTP_300_MULTIPLE_CHOICES
from dump_things_service.instance_state import get_record_dir_config from dump_things_service.instance_state import get_record_dir_config
from dump_things_service.utils import json2yaml
CONFIG_VERSION = 2 CONFIG_VERSION = 2
@ -129,14 +130,7 @@ def main():
if file_type == 'json': if file_type == 'json':
print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output print(json.dumps(configuration, indent=2, sort_keys=False)) # noqa T201 -- cli result output
elif file_type == 'yaml': elif file_type == 'yaml':
print( # noqa T201 -- cli result output print(json2yaml(configuration)) # noqa T201 -- cli result output
yaml.dump(
data=configuration,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
)
)
return 0 return 0

View file

@ -13,6 +13,7 @@ from fastapi_pagination import (
add_pagination, add_pagination,
paginate, paginate,
) )
from starlette.status import HTTP_403_FORBIDDEN
from dump_things_service import ( from dump_things_service import (
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
@ -26,12 +27,16 @@ from dump_things_service.abstract_config import (
from dump_things_service.api_key import api_key_header_scheme from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
from dump_things_service.exceptions import CurieResolutionError from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.instance_state import get_instance_state from dump_things_service.instance_state import (
check_instance_state_collection,
get_instance_state,
)
from dump_things_service.lazy_list import ModifierList from dump_things_service.lazy_list import ModifierList
from dump_things_service.utils import ( from dump_things_service.utils import (
authenticate_token, authenticate_token,
check_bounds, check_bounds,
cleaned_json, cleaned_json,
order_dict,
wrap_http_exception, wrap_http_exception,
) )
@ -56,7 +61,7 @@ async def {name}(
repr(author_id), repr(author_id),
repr({model_var_name}), repr({model_var_name}),
) )
return await store_curated_record( return store_curated_record(
'{collection}', '{collection}',
data, data,
'{class_name}', '{class_name}',
@ -83,6 +88,7 @@ async def read_curated_records_of_type(
matching: str | None = None, matching: str | None = None,
): ):
instance_state = get_instance_state() instance_state = get_instance_state()
check_instance_state_collection(instance_state, collection)
if class_name not in instance_state.collections[collection].active_classes: if class_name not in instance_state.collections[collection].active_classes:
raise HTTPException( raise HTTPException(
status_code=HTTP_404_NOT_FOUND, status_code=HTTP_404_NOT_FOUND,
@ -111,6 +117,7 @@ async def read_curated_records_of_type_paginated(
matching: str | None = None, matching: str | None = None,
) -> Page[dict]: ) -> Page[dict]:
instance_state = get_instance_state() instance_state = get_instance_state()
check_instance_state_collection(instance_state, collection)
if class_name not in instance_state.collections[collection].active_classes: if class_name not in instance_state.collections[collection].active_classes:
raise HTTPException( raise HTTPException(
status_code=HTTP_404_NOT_FOUND, status_code=HTTP_404_NOT_FOUND,
@ -218,6 +225,13 @@ async def _read_curated_records(
if record_info: if record_info:
return record_info.json_object return record_info.json_object
return None return None
# TODO: check whether HTTP_404_NOT_FOUND should be raised instead of
# returning an empty HTTP_200_OK result. This will influence on how the
# endpoint is used by the clients.
# raise HTTPException(
# status_code=HTTP_404_NOT_FOUND,
# detail=f"no record with pid '{pid}' in curated area of collection '{collection}'",
# )
if class_name: if class_name:
result_list = backend.get_records_of_classes([class_name], matching) result_list = backend.get_records_of_classes([class_name], matching)
else: else:
@ -276,7 +290,7 @@ def _get_store_and_backend(
permissions = auth_info.token_permission permissions = auth_info.token_permission
if permissions.curated_write is False: if permissions.curated_write is False:
raise HTTPException( raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED, status_code=HTTP_403_FORBIDDEN,
detail=f'no write access to curated area of collection `{collection}`', detail=f'no write access to curated area of collection `{collection}`',
) )
@ -292,8 +306,8 @@ def store_curated_record(
collection: str, collection: str,
data: BaseModel, data: BaseModel,
class_name: str, class_name: str,
author_id: str | None = None, author_id: str | None,
api_key: str | None = Depends(api_key_header_scheme), api_key: str | None,
): ):
instance_state = get_instance_state() instance_state = get_instance_state()
with wrap_http_exception( with wrap_http_exception(
@ -311,16 +325,17 @@ def store_curated_record(
remove_keys=('@type',), remove_keys=('@type',),
) )
sorted_json_object = order_dict(json_object)
with wrap_http_exception(CurieResolutionError): with wrap_http_exception(CurieResolutionError):
backend.add_record( backend.add_record(
model_store.pid_to_iri(pid), model_store.pid_to_iri(pid),
class_name, class_name,
json_object, sorted_json_object,
) )
for audit_backend in instance_state.audit_backends[collection]: for audit_backend in instance_state.audit_backends[collection]:
audit_backend.add_record( audit_backend.add_record(
record=json_object, record=sorted_json_object,
committer_id=auth_info.user_id, committer_id=auth_info.user_id,
author_id=author_id, author_id=author_id,
) )

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Annotated from typing import TYPE_CHECKING, Annotated
from fastapi import ( from fastapi import (
@ -13,6 +12,7 @@ from fastapi_pagination import (
add_pagination, add_pagination,
paginate, paginate,
) )
from starlette.status import HTTP_403_FORBIDDEN
from dump_things_service import ( from dump_things_service import (
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
@ -28,7 +28,11 @@ from dump_things_service.abstract_config import (
from dump_things_service.api_key import api_key_header_scheme from dump_things_service.api_key import api_key_header_scheme
from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer from dump_things_service.backends.schema_type_layer import _SchemaTypeLayer
from dump_things_service.exceptions import CurieResolutionError from dump_things_service.exceptions import CurieResolutionError
from dump_things_service.instance_state import get_instance_state from dump_things_service.instance_state import (
InstanceState,
check_instance_state_collection,
get_instance_state,
)
from dump_things_service.lazy_list import ModifierList from dump_things_service.lazy_list import ModifierList
from dump_things_service.utils import ( from dump_things_service.utils import (
authenticate_token, authenticate_token,
@ -36,6 +40,7 @@ from dump_things_service.utils import (
cleaned_json, cleaned_json,
create_token_store, create_token_store,
get_on_disk_labels, get_on_disk_labels,
order_dict,
wrap_http_exception, wrap_http_exception,
) )
@ -47,7 +52,6 @@ if TYPE_CHECKING:
from dump_things_service.store.model_store import _ModelStore from dump_things_service.store.model_store import _ModelStore
logger = logging.getLogger('dump_things_service')
router = APIRouter() router = APIRouter()
add_pagination(router) add_pagination(router)
@ -69,7 +73,7 @@ async def incoming_read_labels(
on_disk_labels = get_on_disk_labels( on_disk_labels = get_on_disk_labels(
instance_state.store_path, get_config(), collection instance_state.store_path, get_config(), collection
) )
return list(configured_labels.union(on_disk_labels)) return sorted(configured_labels.union(on_disk_labels))
@router.get( @router.get(
@ -85,11 +89,7 @@ async def incoming_read_records_of_type(
matching: str | None = None, matching: str | None = None,
): ):
instance_state = get_instance_state() instance_state = get_instance_state()
if class_name not in instance_state.collections[collection].active_classes: _check_collection_and_class(instance_state, collection, class_name)
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No '{class_name}'-class in collection '{collection}'.",
)
return await _incoming_read_records( return await _incoming_read_records(
collection=collection, collection=collection,
@ -115,11 +115,7 @@ async def incoming_read_records_of_type_paginated(
matching: str | None = None, matching: str | None = None,
) -> Page[dict]: ) -> Page[dict]:
instance_state = get_instance_state() instance_state = get_instance_state()
if class_name not in instance_state.collections[collection].active_classes: _check_collection_and_class(instance_state, collection, class_name)
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No '{class_name}'-class in collection '{collection}'.",
)
record_list = await _incoming_read_records( record_list = await _incoming_read_records(
collection=collection, collection=collection,
@ -128,10 +124,24 @@ async def incoming_read_records_of_type_paginated(
pid=None, pid=None,
matching=matching, matching=matching,
api_key=api_key, api_key=api_key,
upper_bound=None,
) )
return paginate(record_list) return paginate(record_list)
def _check_collection_and_class(
instance_state: InstanceState,
collection: str,
class_name: str,
):
check_instance_state_collection(instance_state, collection)
if class_name not in instance_state.collections[collection].active_classes:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No '{class_name}'-class in collection '{collection}'.",
)
@router.get( @router.get(
'/{collection}/incoming/{label}/records/', '/{collection}/incoming/{label}/records/',
tags=['Incoming area: read records'], tags=['Incoming area: read records'],
@ -223,7 +233,7 @@ async def _incoming_read_records(
pid: str | None, pid: str | None,
matching: str | None = None, matching: str | None = None,
api_key: str | None = None, api_key: str | None = None,
upper_bound: int = 1000, upper_bound: int | None = 1000,
) -> LazyList | dict | None: ) -> LazyList | dict | None:
model_store, backend = await _get_store_and_backend(collection, label, api_key) model_store, backend = await _get_store_and_backend(collection, label, api_key)
@ -232,6 +242,13 @@ async def _incoming_read_records(
if record_info: if record_info:
return record_info.json_object return record_info.json_object
return None return None
# TODO: check whether HTTP_404_NOT_FOUND should be raised instead of
# returning an empty HTTP_200_OK result. This will influence on how the
# endpoint is used by the clients.
# raise HTTPException(
# status_code=HTTP_404_NOT_FOUND,
# detail=f'no record with pid: {pid}',
# )
if class_name: if class_name:
result_list = backend.get_records_of_classes([class_name], matching) result_list = backend.get_records_of_classes([class_name], matching)
else: else:
@ -242,9 +259,9 @@ async def _incoming_read_records(
len(result_list), len(result_list),
upper_bound, upper_bound,
collection, collection,
f'/incoming/{label}/records/p/{class_name}' f'/incoming/{label}/records/{class_name}'
if class_name if class_name
else f'/incoming/{label}/records/p/', else f'/incoming/{label}/records/',
) )
return ModifierList( return ModifierList(
@ -256,7 +273,7 @@ async def _incoming_read_records(
async def _incoming_delete_record( async def _incoming_delete_record(
collection: str, collection: str,
label: str, label: str,
pid: str | None, pid: str,
api_key: str | None = None, api_key: str | None = None,
) -> bool: ) -> bool:
model_store, backend = await _get_store_and_backend(collection, label, api_key) model_store, backend = await _get_store_and_backend(collection, label, api_key)
@ -274,10 +291,10 @@ async def _incoming_delete_record(
async def _get_store_and_backend( async def _get_store_and_backend(
collection: str, collection: str,
label: str, label: str,
plain_token: str | None, api_key: str | None,
) -> tuple[_ModelStore, StorageBackend]: ) -> tuple[_ModelStore, StorageBackend]:
# Authorize api_key # Authorize api_key
await authorize_zones(collection, plain_token) await authorize_zones(collection, api_key)
# Check that the incoming zone exists # Check that the incoming zone exists
instance_state = get_instance_state() instance_state = get_instance_state()
@ -327,7 +344,7 @@ async def authorize_zones(
permissions = auth_info.token_permission permissions = auth_info.token_permission
if permissions.zones_access is False: if permissions.zones_access is False:
raise HTTPException( raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED, status_code=HTTP_403_FORBIDDEN,
detail=f'no access to incoming zones of collection `{collection}`', detail=f'no access to incoming zones of collection `{collection}`',
) )
@ -337,7 +354,7 @@ async def store_incoming_record(
label: str, label: str,
data: BaseModel, data: BaseModel,
class_name: str, class_name: str,
api_key: str | None = Depends(api_key_header_scheme), api_key: str | None,
): ):
instance_state = get_instance_state() instance_state = get_instance_state()
with wrap_http_exception( with wrap_http_exception(
@ -359,9 +376,10 @@ async def store_incoming_record(
remove_keys=('@type',), remove_keys=('@type',),
) )
sorted_json_object = order_dict(json_object)
with wrap_http_exception(CurieResolutionError): with wrap_http_exception(CurieResolutionError):
backend.add_record( backend.add_record(
model_store.pid_to_iri(pid), model_store.pid_to_iri(pid),
class_name, class_name,
json_object, sorted_json_object,
) )

View file

@ -9,9 +9,11 @@ from typing import (
) )
import yaml import yaml
from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from yaml.scanner import ScannerError from yaml.scanner import ScannerError
from dump_things_service import HTTP_404_NOT_FOUND
from dump_things_service.abstract_config import ( from dump_things_service.abstract_config import (
MappingMethod, MappingMethod,
RecordDirConfigFileContent, RecordDirConfigFileContent,
@ -155,3 +157,14 @@ def get_mapping_function_by_name(mapping_function_name: str) -> Callable:
def get_mapping_function(collection_config: RecordDirConfigFileContent): def get_mapping_function(collection_config: RecordDirConfigFileContent):
return mapping_functions[collection_config.idfx] return mapping_functions[collection_config.idfx]
def check_instance_state_collection(
instance_state: InstanceState,
collection: str,
):
if collection not in instance_state.collections:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"No such collection: '{collection}'.",
)

View file

@ -395,6 +395,7 @@ def fastapi_client_simple(fastapi_app_simple):
response = test_client.post( response = test_client.post(
'/collections', '/collections',
json=collection_config.model_dump( json=collection_config.model_dump(
exclude_none=True,
exclude_unset=True, exclude_unset=True,
mode='json', mode='json',
by_alias=True, by_alias=True,
@ -407,7 +408,11 @@ def fastapi_client_simple(fastapi_app_simple):
for token_config in g_default_tokens: for token_config in g_default_tokens:
response = test_client.post( response = test_client.post(
'/tokens', '/tokens',
json=token_config.model_dump(exclude_unset=True, mode='json'), json=token_config.model_dump(
exclude_none=True,
exclude_unset=True,
mode='json',
),
headers={'x-dumpthings-token': admin_token}, headers={'x-dumpthings-token': admin_token},
) )
assert response.status_code == 201 assert response.status_code == 201

View file

@ -0,0 +1,56 @@
from pathlib import Path
from dump_things_service import HTTP_200_OK
from dump_things_service.abstract_config import (
MappingMethod,
mapping_functions,
)
def test_canonicalization(fastapi_client_simple):
test_client, store_path, _admin_token = fastapi_client_simple
pid = 'http://example.com/test_canonicalization/1'
record_a = {
'pid': pid,
'given_name': 'Alice',
}
record_b = {
'given_name': 'Bob',
'pid': pid,
}
# Ensure that the two records are represented in the same order on disk.
# (The current implementation should guarantee that because the pydantic
# objects that are instantiated from the JSON input have a fixed order
# of attributes. Once the implementation is changed, this test here might
# catch regressions).
file_content = []
for record in (record_a, record_b):
response = test_client.post(
'/collection_1/record/Person',
headers={'x-dumpthings-token': 'token-1'},
json=record,
)
assert response.status_code == HTTP_200_OK
file_content.append(
_get_record_dir_record(
store_path,
'incoming/collection_1/in_token_1/Person',
pid,
MappingMethod.digest_md5,
),
)
assert file_content[0] == file_content[1].replace('Bob', 'Alice')
def _get_record_dir_record(
store_path: Path,
path_part: str | Path,
pid: str,
mapping_method: MappingMethod,
) -> str:
file_name = mapping_functions[mapping_method](pid, 'yaml')
with (store_path / path_part / file_name).open('rt') as f:
return f.read()

View file

@ -15,9 +15,11 @@ from contextlib import contextmanager
from functools import reduce from functools import reduce
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
cast,
) )
import fsspec import fsspec
import yaml
from fastapi import HTTPException from fastapi import HTTPException
from rdflib import Graph from rdflib import Graph
@ -30,6 +32,7 @@ from dump_things_service import (
) )
from dump_things_service.abstract_config import ( from dump_things_service.abstract_config import (
Configuration, Configuration,
RecordDirBackendConfig,
TokenModes, TokenModes,
TokenPermission, TokenPermission,
check_collection, check_collection,
@ -89,12 +92,14 @@ def cleaned_json(data: JSON, remove_keys: tuple[str, ...] = ('@type',)) -> JSON:
def combine_ttl(documents: list[str]) -> str: def combine_ttl(documents: list[str]) -> str:
graphs = [Graph().parse(data=doc, format='ttl') for doc in documents] graphs = [Graph().parse(data=doc, format='ttl') for doc in documents]
if not graphs:
return ''
return reduce(lambda g1, g2: g1 + g2, graphs).serialize(format='ttl') return reduce(lambda g1, g2: g1 + g2, graphs).serialize(format='ttl')
@contextmanager @contextmanager
def wrap_http_exception( def wrap_http_exception(
exception_class: type[BaseException] = ValueError, exception_class: type[Exception] = ValueError,
status_code: int = HTTP_400_BAD_REQUEST, status_code: int = HTTP_400_BAD_REQUEST,
header: str = '', header: str = '',
): ):
@ -154,7 +159,12 @@ def get_on_disk_labels(
) -> set[str]: ) -> set[str]:
check_collection(abstract_config, collection) check_collection(abstract_config, collection)
incoming_path = store_path / abstract_config.collections[collection].incoming if abstract_config.collections[collection].incoming is None:
return set()
incoming_path = store_path / cast(
'Path', abstract_config.collections[collection].incoming
)
if not incoming_path or not incoming_path.exists(): if not incoming_path or not incoming_path.exists():
return set() return set()
@ -221,31 +231,26 @@ def get_token_store(
abstract_config: Configuration, abstract_config: Configuration,
instance_state: InstanceState, instance_state: InstanceState,
collection_name: str, collection_name: str,
token_representation: str | None, token_representation: str,
*, *,
is_token_name: bool = False, is_token_name: bool = False,
) -> tuple[_ModelStore, TokenPermission, str] | tuple[None, None, None, None]: ) -> tuple[_ModelStore, TokenPermission, str] | tuple[None, None, None]:
# If a token representation is provided, try to authenticate the token # If a token representation is provided, try to authenticate the token
# with the authentication providers that are associated with the collection. # with the authentication providers that are associated with the collection.
if not is_token_name: if not is_token_name:
if token_representation is None:
msg = 'get_token_store: token_representation is None and is_token_name is False. This is a calling error!'
logger.error(msg)
raise ValueError(msg)
auth_info = authenticate_token( auth_info = authenticate_token(
instance_state, instance_state,
collection_name, collection_name,
token_representation, token_representation,
) )
token_key = token_representation
else: else:
auth_info = get_default_token_auth_info( auth_info = get_default_token_auth_info(
abstract_config=abstract_config, abstract_config=abstract_config,
collection_name=collection_name, collection_name=collection_name,
token_name=token_representation, token_name=token_representation,
) )
token_representation = None token_key = None
if not auth_info: if not auth_info:
detail = f"invalid token for collection '{collection_name}'" detail = f"invalid token for collection '{collection_name}'"
@ -258,12 +263,12 @@ def get_token_store(
# If the token has no incoming-read or incoming-write permissions, we do not # If the token has no incoming-read or incoming-write permissions, we do not
# need to create a store. # need to create a store.
if not permissions.incoming_read and not permissions.incoming_write: if not permissions.incoming_read and not permissions.incoming_write:
instance_state.incoming_stores[collection_name][token_representation] = ( instance_state.incoming_stores[collection_name][token_key] = (
None, None,
permissions, permissions,
auth_info.user_id, auth_info.user_id,
) )
return instance_state.incoming_stores[collection_name][token_representation] return instance_state.incoming_stores[collection_name][token_key]
# Check whether the collection has an incoming definition # Check whether the collection has an incoming definition
incoming = abstract_config.collections[collection_name].incoming incoming = abstract_config.collections[collection_name].incoming
@ -274,9 +279,7 @@ def get_token_store(
) )
# Check whether a store for this collection and token does already exist. # Check whether a store for this collection and token does already exist.
store_info = instance_state.incoming_stores[collection_name].get( store_info = instance_state.incoming_stores[collection_name].get(token_key)
token_representation
)
if store_info: if store_info:
return store_info return store_info
@ -288,12 +291,12 @@ def get_token_store(
store_dir=store_dir, store_dir=store_dir,
) )
instance_state.incoming_stores[collection_name][token_representation] = ( instance_state.incoming_stores[collection_name][token_key] = (
token_store, token_store,
permissions, permissions,
auth_info.user_id, auth_info.user_id,
) )
return instance_state.incoming_stores[collection_name][token_representation] return instance_state.incoming_stores[collection_name][token_key]
def create_store( def create_store(
@ -353,7 +356,9 @@ def create_token_store(
store_dir=store_dir, store_dir=store_dir,
order_by=instance_state.order_by, order_by=instance_state.order_by,
schema_uri=schema_uri, schema_uri=schema_uri,
mapping_function=backend_config.mapping_method, mapping_function=cast(
'RecordDirBackendConfig', backend_config
).mapping_method,
suffix='yaml', suffix='yaml',
) )
elif backend_name == 'sqlite': elif backend_name == 'sqlite':
@ -449,7 +454,7 @@ def create_sqlite_token_store_backend(
def check_bounds( def check_bounds(
length: int | None, max_length: int, collection: str, alternative_url: str length: int | None, max_length: int, collection: str, alternative_url: str
): ):
if length > max_length: if length is not None and length > max_length:
raise HTTPException( raise HTTPException(
status_code=HTTP_413_CONTENT_TOO_LARGE, status_code=HTTP_413_CONTENT_TOO_LARGE,
detail=f"Too many records found in collection '{collection}'. " detail=f"Too many records found in collection '{collection}'. "
@ -477,7 +482,14 @@ async def process_token(
abstract_config, abstract_config,
instance_state, instance_state,
collection, collection,
api_key, token_representation=api_key,
is_token_name=False,
)
if token_permissions is None:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=f"No read access to curated or incoming data in collection '{collection}'.",
) )
final_permissions = join_default_token_permissions( final_permissions = join_default_token_permissions(
@ -527,3 +539,30 @@ def var_escape(
name: str, name: str,
) -> str: ) -> str:
return name.replace('_', '___').replace('-', '_0_') return name.replace('_', '___').replace('-', '_0_')
def json2yaml(
json_object: dict,
) -> str:
return yaml.dump(
data=json_object,
sort_keys=True,
allow_unicode=True,
default_flow_style=False,
)
def order_dict(
d: dict,
) -> dict:
return cast('dict', _order_dict(d))
def _order_dict(
d: dict | list | str | int | None,
) -> dict | list | str | int | None:
if isinstance(d, dict):
return {k: order_dict(d[k]) for k in sorted(d)}
if isinstance(d, list):
return [order_dict(e) for e in d]
return d