Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/simdb/remote/apis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ def handle_authentication_error(err: Exception):
class Index(Resource):
@api.doc(security=[])
def get(self):
"""Describe the API root.

Returns basic information about this API version, including the
server version and the URLs of the top-level endpoints and the
documentation. Requires no authentication.
"""
return jsonify(
{
"api": "simdb",
Expand All @@ -101,6 +107,12 @@ class Token(Resource):
@api.response(401, "Unauthorized")
@requires_auth()
def get(self, user: User):
"""Issue an authentication token.

Exchanges HTTP basic-auth credentials for a signed JWT that can be
used to authenticate subsequent requests. The token expires after
the server-configured lifetime.
"""
auth = request.authorization
if auth is None:
return error("Authorization invalid")
Expand Down Expand Up @@ -128,13 +140,24 @@ def get(self, user: User):
class ValidationSchema(Resource):
@requires_auth()
def get(self, user: User):
"""Return the configured validation schemas.

Returns the metadata validation schemas the server applies to
simulations, as configured on the server.
"""
config = current_app.simdb_config
return jsonify(Validator.validation_schemas(config, None))

@api.route("/upload_options")
class UploadOptions(Resource):
@requires_auth()
def get(self, user: User):
"""Return the server's upload options.

Returns the server-side upload behaviour flags clients should honour
when pushing simulations, such as whether files and IMAS data are
copied onto the server.
"""
config = current_app.simdb_config
options = {
"copy_files": config.get_option("server.copy_files", default=True),
Expand Down
30 changes: 30 additions & 0 deletions src/simdb/remote/apis/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,24 @@ class FileList(Resource):
@requires_auth()
@pydantic_validate(api)
def get(self, user: User) -> FileDataList:
"""List all registered files.

Returns every input and output file known to the database across all
simulations.
"""
files = current_app.db.list_files()
return FileDataList.model_validate([file.to_model() for file in files])

@requires_auth()
def post(self, user: User):
"""Register or upload simulation files.

Handles two content types. A JSON body registers file metadata and
verifies each file's checksum against the copy already present in the
simulation's staging directory. A multipart form upload streams file
content (optionally gzip-compressed and chunked) into the staging
directory ahead of registration.
"""
try:
if request.is_json:
body = FileRegistrationData.model_validate_json(request.get_data())
Expand All @@ -197,6 +210,11 @@ class File(Resource):
@requires_auth()
@pydantic_validate(api)
def get(self, file_uuid: str, user: Optional[User] = None) -> FileGetDataResponse:
"""Retrieve a single file's metadata.

Returns the stored record for the file identified by ``file_uuid``,
including its resolved on-disk path.
"""
file = current_app.db.get_file(file_uuid)
return file.to_model_with_path()

Expand All @@ -205,6 +223,12 @@ def get(self, file_uuid: str, user: Optional[User] = None) -> FileGetDataRespons
class NonIMASFileDownload(Resource):
@requires_auth()
def get(self, file_uuid: str, user: Optional[User] = None):
"""Download a non-IMAS file.

Streams the raw contents of the plain (non-IMAS) file identified by
``file_uuid`` back to the client, with a MIME type inferred from the
file itself.
"""
try:
file: models.File = current_app.db.get_file(file_uuid)
if file.type != DataType.FILE:
Expand All @@ -221,6 +245,12 @@ def get(self, file_uuid: str, user: Optional[User] = None):
class FileDownload(Resource):
@requires_auth()
def get(self, file_uuid: str, file_index: int, user: Optional[User] = None):
"""Download one file from a file entry by index.

Streams a single physical file back to the client. For a plain file
only index ``0`` is valid. For an IMAS entry, which maps to several
physical files, ``file_index`` selects which one to download.
"""
try:
file: models.File = current_app.db.get_file(file_uuid)
if file.type == DataType.FILE:
Expand Down
11 changes: 11 additions & 0 deletions src/simdb/remote/apis/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class MetaData(Resource):
@cache.cached(key_prefix=cache_key) # type: ignore
@pydantic_validate(api)
def get(self) -> MetadataKeyInfoList:
"""List all metadata keys.

Returns every distinct metadata key present across the database,
together with information about each key. Use this to discover which
keys are available to filter or sort simulations by.
"""
return MetadataKeyInfoList.model_validate(current_app.db.list_metadata_keys())


Expand All @@ -21,6 +27,11 @@ class MetaDataValues(Resource):
@cache.cached(key_prefix=cache_key) # type: ignore
@pydantic_validate(api)
def get(self, name: str) -> MetadataValueList:
"""List all values for a metadata key.

Returns every distinct value stored across the database for the given
metadata key ``name``.
"""
return MetadataValueList.model_validate(
current_app.db.list_metadata_values(name)
)
7 changes: 7 additions & 0 deletions src/simdb/remote/apis/v1_2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class StagingDirectory(Resource):
@requires_auth()
@pydantic_validate(api)
def get(self, sim_hex: str, user: User) -> StagingDirectoryResponse:
"""Get (and create) a staging directory for file uploads.

Returns the staging directory path clients should upload simulation
files to before ingesting a simulation. Without ``sim_hex`` the base
upload folder is returned. With a ``sim_hex`` the per-simulation
subdirectory is created if needed and its path returned.
"""
upload_dir = current_app.simdb_config.get_string_option(
"server.user_upload_folder", default=None
)
Expand Down
118 changes: 103 additions & 15 deletions src/simdb/remote/apis/v1_2/simulations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@
import tarfile
from io import BytesIO
from pathlib import Path
from typing import Annotated, List, Optional, Tuple
from typing import Annotated, Optional

from flask import request, send_file
from flask import send_file
from flask_restx import Namespace, Resource

from simdb.database import DatabaseError
from simdb.database.models import simulation as models_sim
from simdb.database.models import watcher as models_watcher
from simdb.email.server import EmailServer
from simdb.imas.utils import SimDBUrl, convert_uri
from simdb.query import QueryType, parse_query_arg
from simdb.remote.core.alias import create_alias_dir
from simdb.remote.core.auth import User, requires_auth
from simdb.remote.core.cache import cache, cache_key, clear_cache
Expand All @@ -24,6 +23,7 @@
from simdb.remote.core.pydantic_utils import (
Body,
Header,
Query,
ResponseException,
pydantic_validate,
)
Expand All @@ -42,6 +42,7 @@
SimulationPatchResponse,
SimulationPostData,
SimulationPostResponse,
SimulationQueryParams,
SimulationTraceData,
StatusPatchData,
ValidationResult,
Expand Down Expand Up @@ -165,24 +166,45 @@ def get_meta_val(key, default=None):
class SimulationList(Resource):
@requires_auth()
@pydantic_validate(api)
@api.doc(
params={
"<metadata_key>": {
"description": (
"Any metadata key may be supplied as a query parameter to "
"filter on that metadata. The value is matched for equality "
"by default, or may use a ``comparator:value`` expression "
"(comparators: eq, ne, in, ni, gt, ge, lt, le, agt, age, "
"alt, ale, exist), e.g. ``status=passed`` or "
"``runtime=gt:100``. Repeat a key to apply several "
"constraints."
),
"in": "query",
"type": "string",
},
}
)
# @cache.cached(key_prefix=cache_key)
def get(
self,
user: User,
pagination: Annotated[PaginationData, Header()],
filters: Annotated[SimulationQueryParams, Query()],
) -> PaginatedResponse[SimulationListItem]:
names = []
constraints = []
if request.args:
constraints: List[Tuple[str, str, QueryType]] = []
for name in request.args:
if name not in ("alias", "uuid"):
names.append(name)
values = request.args.getlist(name)
for value in values:
constraint = parse_query_arg(value)
if constraint[0] or constraint[1] == QueryType.EXIST:
constraints.append((name, *constraint))
"""List simulations, optionally filtered by metadata.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the description here enters too much into details as is a duplication of info w.r.t parameters you describe in line 172


Returns a paginated list of simulations. Query parameters are
interpreted as metadata constraints, so passing a metadata key and a
query value (for example ``status=passed`` or ``runtime=gt:100``)
filters the results to matching simulations. Values are matched for
equality by default, or may use a ``comparator:value`` expression
(comparators: ``eq``, ``ne``, ``in``, ``ni``, ``gt``, ``ge``, ``lt``,
``le``, ``agt``, ``age``, ``alt``, ``ale``, ``exist``). The special
``alias`` and ``uuid`` parameters filter on the simulation's identity
rather than its metadata. Without any query parameters all simulations
are returned. Use the pagination headers to control page size, page
number and sorting.
"""
names, constraints = filters.constraints()

if constraints:
count, data = current_app.db.query_meta_data(
Expand Down Expand Up @@ -218,6 +240,17 @@ def post(
user: User,
body: Annotated[SimulationPostData, Body()],
) -> SimulationPostResponse:
"""Ingest (upload) a new simulation.

Registers a simulation and its input and output files in the database.
The upload timestamp and the uploading user are recorded automatically.
If the server is configured to copy files, referenced files are moved
from the per-simulation staging directory into permanent storage. When
auto-validation is enabled the simulation is validated on ingest and the
result is returned. If the simulation declares that it replaces an
earlier one, the replaced simulation is marked deprecated. An alias may
be requested; aliases ending in ``-`` or ``#`` are auto-numbered.
"""
simulation = models_sim.Simulation.from_data_model(body.simulation)

# Simulation Upload (Push) Date
Expand Down Expand Up @@ -344,6 +377,13 @@ class Simulation(Resource):
@cache.cached(key_prefix=cache_key) # type: ignore
@pydantic_validate(api)
def get(self, sim_id: str, user: User) -> SimulationDataResponse:
"""Retrieve a single simulation by id or alias.

Returns the full simulation record, including its input and output
files and metadata, together with references to its parent and child
simulations. The ``sim_id`` path parameter accepts either a simulation
UUID or an alias.
"""
try:
simulation = current_app.db.get_simulation(sim_id)
except DatabaseError:
Expand All @@ -365,6 +405,12 @@ def patch(
user: Optional[User],
body: Annotated[StatusPatchData, Body()],
) -> SimulationPatchResponse:
"""Update a simulation's status.

Sets the simulation's status to the value given in the request body.
If the status changes, any users watching the simulation are notified
by email. Requires admin privileges.
"""
simulation = current_app.db.get_simulation(sim_id)
if simulation is None:
raise ResponseException(f"Simulation {sim_id} not found.")
Expand All @@ -377,6 +423,13 @@ def patch(
@requires_auth("admin")
@pydantic_validate(api)
def delete(self, sim_id: str, user: User) -> SimulationDeleteResponse:
"""Delete a simulation and its stored files.

Removes the simulation from the database and deletes its staging
directory and any alias symlink from disk. Returns the deleted
simulation's id and the list of files that were removed. Requires admin
privileges.
"""
simulation = current_app.db.delete_simulation(sim_id)
clear_cache()

Expand Down Expand Up @@ -406,6 +459,11 @@ class SimulationMeta(Resource):
@cache.cached(key_prefix=cache_key) # type: ignore
@pydantic_validate(api)
def get(self, sim_id: str, user: User) -> MetadataDataList:
"""List a simulation's metadata.

Returns all metadata entries (key/value pairs) attached to the
simulation identified by ``sim_id`` (UUID or alias).
"""
simulation = current_app.db.get_simulation(sim_id)
if simulation:
return MetadataDataList.model_validate(
Expand All @@ -421,6 +479,13 @@ def patch(
user: Optional[User],
body: Annotated[MetadataPatchData, Body()],
) -> MetadataDataList:
"""Set or update a metadata entry on a simulation.

Writes the given metadata key/value pair to the simulation and returns
the previous value(s) for that key. Updating the ``status`` key routes
through the status-change logic and notifies watchers. Requires admin
privileges.
"""
key = body.key
value = body.value.lower()
simulation = current_app.db.get_simulation(sim_id)
Expand All @@ -447,6 +512,11 @@ def delete(
user: Optional[User],
body: Annotated[MetadataDeleteData, Body()],
) -> MetadataDeleteResponse:
"""Remove a metadata entry from a simulation.

Deletes the metadata key given in the request body from the simulation.
Requires admin privileges.
"""
simulation = current_app.db.get_simulation(sim_id)
if simulation is None:
raise ResponseException(f"Simulation {sim_id} not found.")
Expand All @@ -462,6 +532,12 @@ class ValidateSimulation(Resource):
@requires_auth()
@pydantic_validate(api)
def post(self, sim_id, user: User) -> ValidationResult:
"""Validate a simulation against its schemas.

Runs the configured metadata and file validators against the simulation
and updates its status to passed or failed accordingly. Returns whether
validation passed along with any validation error message.
"""
simulation = current_app.db.get_simulation(sim_id)
result = _validate(simulation, user)
current_app.db.insert_simulation(simulation)
Expand All @@ -475,13 +551,25 @@ class SimulationTrace(Resource):
@cache.cached(key_prefix=cache_key) # type: ignore
@pydantic_validate(api)
def get(self, sim_id: str, user: User) -> SimulationTraceData:
"""Trace a simulation's provenance chain.

Returns the simulation's trace data, recursively resolving the chain of
simulations it replaces so the full deprecation and replacement history
can be followed. Includes status, status timestamps and replacement
reasons.
"""
return _build_trace(sim_id)


@api.route("/simulation/package/<path:sim_id>")
class SimulationPackage(Resource):
@requires_auth()
def get(self, sim_id: str, user: User):
"""Download a simulation's files as a gzipped tar archive.

Packages the simulation's staging directory into a ``.tar.gz`` archive
and streams it back as an ``application/x-gzip`` download.
"""
try:
simulation = current_app.db.get_simulation(sim_id)

Expand Down
Loading
Loading