Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
echo "slug=${slug}" >> $GITHUB_OUTPUT
- name: "🛍️ Checkout repository"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref || inputs.version }}

Expand Down
15 changes: 13 additions & 2 deletions clams/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import os
import pathlib
Expand All @@ -19,6 +20,7 @@
)
from mmif.utils.workflow_helper import generate_param_hash # pytype: disable=import-error
from clams.appmetadata import AppMetadata, real_valued_primitives, python_type, map_param_kv_delimiter
from clams.envelop import unwrap_if_envelope

logging.basicConfig(
level=getattr(logging, os.environ.get('CLAMS_LOGLEVEL', 'WARNING').upper(), logging.WARNING),
Expand Down Expand Up @@ -159,11 +161,19 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) ->
wrapper around :meth:`~clams.app.ClamsApp._annotate` method where some common operations
(that are invoked by keyword arguments) are implemented.

:param mmif: An input MMIF object to annotate
The input may be a raw MMIF (str, dict, or :class:`~mmif.serialize.mmif.Mmif`)
or a JSON envelope wrapping both ``"parameters"`` and ``"mmif"``.
Envelope detection and unwrapping happen here so every execution
path (HTTP, CLI, direct Python API) is envelope-aware. When an
envelope is given, its parameters are merged under ``runtime_params``
(explicitly-passed parameters take priority on key collision).

:param mmif: An input MMIF object, or a JSON envelope, to annotate
:param runtime_params: An arbitrary set of k-v pairs to configure the app at runtime
:return: Serialized JSON string of the output of the app
"""
if not isinstance(mmif, Mmif):
mmif, runtime_params = unwrap_if_envelope(mmif, runtime_params)
mmif = Mmif(mmif)
existing_view_ids = {view.id for view in mmif.views}
issued_warnings = []
Expand Down Expand Up @@ -329,7 +339,8 @@ def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str]
:return: An output MMIF with a new view with the error encoded in the view metadata
"""
import traceback
if isinstance(mmif, bytes) or isinstance(mmif, str) or isinstance(mmif, dict):
if isinstance(mmif, (bytes, str, dict)):
mmif, runtime_conf = unwrap_if_envelope(mmif, runtime_conf)
mmif = Mmif(mmif)
error_view: Optional[View] = None
for view in reversed(mmif.views):
Expand Down
46 changes: 43 additions & 3 deletions clams/envelop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
MMIF_KEY = 'mmif'


class EnvelopeError(ValueError):
"""
Raised when an input body is detected as an envelope (has a
``"parameters"`` key) but is otherwise malformed. Subclasses
``ValueError`` so existing ``except ValueError`` handlers keep
working, while still being distinguishable from unrelated
``ValueError``\\ s raised by app code.
"""


def normalize_params(params: dict) -> Dict[str, List[str]]:
"""
Normalize JSON-native parameter values to the
Expand Down Expand Up @@ -56,23 +66,53 @@ def unwrap_envelope(body: dict) -> Tuple[str, Dict[str, List[str]]]:
:param body: parsed JSON dict with ``"parameters"`` and ``"mmif"``
:returns: tuple of (mmif_json_string, normalized_params)
:rtype: Tuple[str, Dict[str, List[str]]]
:raises ValueError: if ``"mmif"`` key is missing or
:raises EnvelopeError: if ``"mmif"`` key is missing or
``"parameters"`` is not a dict
"""
params = body.get(ENVELOPE_KEY)
if not isinstance(params, dict):
raise ValueError(
raise EnvelopeError(
f'"{ENVELOPE_KEY}" must be a JSON object, '
f'got {type(params).__name__}'
)
if MMIF_KEY not in body:
raise ValueError(
raise EnvelopeError(
f'Envelope is missing required "{MMIF_KEY}" key'
)
mmif_str = json.dumps(body[MMIF_KEY])
return mmif_str, normalize_params(params)


def unwrap_if_envelope(data, runtime_params):
"""
If ``data`` is (or decodes to) an envelope, return the inner MMIF
together with envelope parameters merged under the explicitly-passed
``runtime_params`` (so query-string / CLI flags take priority). If
``data`` is not an envelope, return it unchanged.

This is the single entry point used by every execution path
(HTTP, CLI, direct Python API) so envelope handling is uniform
regardless of how the app is invoked.

:param data: raw input -- ``bytes``, ``str``, or ``dict``
:param runtime_params: explicitly-passed parameters that override
envelope parameters on key collision
:returns: tuple of (mmif_or_original_data, effective_params)
:raises EnvelopeError: if ``data`` is a malformed envelope
"""
raw = data.decode('utf-8') if isinstance(data, bytes) else data
body = raw
if isinstance(raw, str):
try:
body = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return data, runtime_params
if is_envelope(body):
inner_mmif, envelope_params = unwrap_envelope(body)
return inner_mmif, {**envelope_params, **runtime_params}
return data, runtime_params


def create_envelope(
mmif: Union[str, dict, Mmif],
parameters: Optional[dict] = None,
Expand Down
46 changes: 14 additions & 32 deletions clams/restify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
import jsonschema
from flask import Flask, request, Response
from flask_restful import Resource, Api
from mmif import Mmif

from clams.app import ClamsApp
from clams.envelop import is_envelope, unwrap_envelope
from clams.envelop import EnvelopeError


class Restifier(object):
Expand Down Expand Up @@ -191,50 +190,33 @@ def post(self) -> Response:
Maps HTTP POST verb to :meth:`~clams.app.ClamsApp.annotate`.
Note that for now HTTP PUT verbs is also mapped to :meth:`~clams.app.ClamsApp.annotate`.

The request body can be either raw MMIF JSON or a JSON envelope
containing both ``"parameters"`` and ``"mmif"`` keys. When an
envelope is detected, parameters are normalized and merged with
any query-string parameters (query string takes priority).

:return: Returns MMIF output from a ClamsApp in a HTTP response.
"""
raw_data = request.get_data().decode('utf-8')
# this will catch duplicate arguments with different values into a list under the key
raw_params = request.args.to_dict(flat=False)
try:
body = json.loads(raw_data)
except (json.JSONDecodeError, ValueError):
return Response(
response="Invalid JSON in request body.",
status=500, mimetype='text/plain')
if is_envelope(body):
try:
mmif_data, envelope_params = unwrap_envelope(body)
except ValueError as e:
return Response(
response=f"Invalid envelope format: {e}",
status=500, mimetype='text/plain')
# query string overrides envelope parameters
params = {**envelope_params, **raw_params}
else:
mmif_data = raw_data
params = raw_params
try:
_ = Mmif(mmif_data)
except jsonschema.exceptions.ValidationError as e:
return self.json_to_response(
self.cla.annotate(raw_data, **raw_params))
except (jsonschema.exceptions.ValidationError,
json.JSONDecodeError, EnvelopeError) as e:
# jsonschema's str(e) dumps the entire MMIF schema; use its
# concise .message instead so envelope and MMIF input
# errors share the same compact payload format.
detail = (
e.message
if isinstance(e, jsonschema.exceptions.ValidationError)
else str(e))
return Response(
response="Invalid input data. "
"See below for validation error.\n\n"
+ str(e),
+ detail,
status=500, mimetype='text/plain')
try:
return self.json_to_response(
self.cla.annotate(mmif_data, **params))
except Exception:
self.cla.logger.exception("Error in annotation")
return self.json_to_response(
self.cla.record_error(
mmif_data, **params
raw_data, **raw_params
).serialize(pretty=True),
status=500)

Expand Down
Loading
Loading