diff --git a/tests/functional/application-packages/EchoResultsTester/package.cwl b/tests/functional/application-packages/EchoResultsTester/package.cwl index 1f54a8f59..08d6ace3c 100644 --- a/tests/functional/application-packages/EchoResultsTester/package.cwl +++ b/tests/functional/application-packages/EchoResultsTester/package.cwl @@ -4,18 +4,34 @@ baseCommand: echo requirements: DockerRequirement: dockerPull: "debian:stretch-slim" + InlineJavascriptRequirement: {} + InitialWorkDirRequirement: + listing: + - entryname: result.json + entry: | + {"data":"$(inputs.message)"} + - entryname: result.txt + entry: | + $(inputs.message) inputs: message: type: string inputBinding: position: 1 outputs: - output_reference: - type: File - outputBinding: - glob: "stdout.log" output_data: type: string outputBinding: outputEval: $(inputs.message) -stdout: stdout.log + output_text: + type: File + outputBinding: + glob: result.txt + format: "iana:text/plain" + output_json: + type: File + outputBinding: + glob: result.json + format: "iana:application/json" +$namespaces: + iana: "https://www.iana.org/assignments/media-types/" diff --git a/tests/functional/test_wps_package.py b/tests/functional/test_wps_package.py index 2f0e6de64..8b28f951c 100644 --- a/tests/functional/test_wps_package.py +++ b/tests/functional/test_wps_package.py @@ -7,11 +7,14 @@ .. seealso:: - :mod:`tests.processes.wps_package`. """ +import inspect + import contextlib import copy import json import logging import os +import re import shutil import tempfile from inspect import cleandoc @@ -71,7 +74,7 @@ ) from weaver.processes.types import ProcessType from weaver.status import Status -from weaver.utils import fetch_file, get_any_value, get_path_kvp, load_file +from weaver.utils import fetch_file, get_any_value, get_path_kvp, load_file, parse_kvp from weaver.wps.utils import get_wps_output_dir, get_wps_output_url, map_wps_output_location from weaver.wps_restapi import swagger_definitions as sd @@ -3519,16 +3522,49 @@ def test_execute_cwl_enum_schema_combined_type_single_array_from_wps(self, mock_ assert results def test_execute_single_output_prefer_header_return_representation(self): - body = self.retrieve_payload("EchoResultsTester", "deploy", local=True) - desc = self.deploy_process(body) + proc = "EchoResultsTester" + p_id = self.fully_qualified_test_process_name(proc) + body = self.retrieve_payload(proc, "deploy", local=True) + self.deploy_process(body, process_id=p_id) exec_headers = { - "Prefer": f"return={ExecuteReturnPreference.REPRESENTATION}" + "Prefer": f"return={ExecuteReturnPreference.REPRESENTATION}, respond-async" + } + exec_headers.update(self.json_headers) + exec_content = { + "inputs": { + "message": "test" + }, + "outputs": { + "output_json": {} # no 'transmissionMode' to auto-resolve 'value' from 'return=representation' + } } with contextlib.ExitStack() as stack: for mock_exec in mocked_execute_celery(): stack.enter_context(mock_exec) - raise NotImplementedError # FIXME: implement + path = f"/processes/{p_id}/execution" + resp = mocked_sub_requests(self.app, "post_json", path, timeout=5, + data=exec_content, headers=exec_headers, only_local=True) + assert resp.status_code == 201, f"Failed with: [{resp.status_code}]\nReason:\n{resp.json}" + + # request status instead of results since not expecting 'document' JSON in this case + status_url = resp.json["location"] + status = self.monitor_job(status_url, return_status=True) + assert status["status"] == Status.SUCCEEDED + + job_id = status["jobID"] + out_url = get_wps_output_url(self.settings) + results = self.app.get(f"/jobs/{job_id}/results") + assert results.content_type.startswith(ContentType.APP_JSON) + outputs = self.app.get(f"/jobs/{job_id}/outputs") + output_json = json.dumps({"data": "test"}, separators=(",", ":")) + assert results.text == output_json + assert outputs.json == { + "output_json": { + "href": f"{out_url}/{job_id}/output_json/output.json", + "type": ContentType.APP_JSON, + }, + } def test_execute_single_output_prefer_header_return_minimal(self): raise NotImplementedError # FIXME: implement @@ -3540,7 +3576,68 @@ def test_execute_single_output_response_raw_reference(self): raise NotImplementedError # FIXME: implement def test_execute_multi_output_prefer_header_return_representation(self): - raise NotImplementedError # FIXME: implement + proc = "EchoResultsTester" + p_id = self.fully_qualified_test_process_name(proc) + body = self.retrieve_payload(proc, "deploy", local=True) + self.deploy_process(body, process_id=p_id) + + exec_headers = { + "Prefer": f"return={ExecuteReturnPreference.REPRESENTATION}, respond-async" + } + exec_headers.update(self.json_headers) + exec_content = { + "inputs": { + "message": "test" + }, + "outputs": { + # no 'transmissionMode' to auto-resolve 'value' from 'return=representation' + # request multiple outputs, but not 'all', to test filter behavior at the same time + # use 1 expected as 'File' and 1 'string' literal to test conversion to raw 'value' + "output_json": {}, + "output_data": {} + } + } + with contextlib.ExitStack() as stack: + for mock_exec in mocked_execute_celery(): + stack.enter_context(mock_exec) + path = f"/processes/{p_id}/execution" + resp = mocked_sub_requests(self.app, "post_json", path, timeout=5, + data=exec_content, headers=exec_headers, only_local=True) + assert resp.status_code == 201, f"Failed with: [{resp.status_code}]\nReason:\n{resp.json}" + + # request status instead of results since not expecting 'document' JSON in this case + status_url = resp.json["location"] + status = self.monitor_job(status_url, return_status=True) + assert status["status"] == Status.SUCCEEDED + + job_id = status["jobID"] + out_url = get_wps_output_url(self.settings) + results = self.app.get(f"/jobs/{job_id}/results") + assert results.content_type.startswith(ContentType.MULTIPART_RELATED) + boundary = parse_kvp(results.content_type)["boundary"][0] + outputs = self.app.get(f"/jobs/{job_id}/outputs") + output_json = json.dumps({"data": "test"}, separators=(",", ":")) + results_body = inspect.cleandoc(f""" + --{boundary} + Content-Type: {ContentType.TEXT_PLAIN} + Content-ID: output_data + + test + --{boundary} + Content-Type: {ContentType.APP_JSON} + Content-ID: output_json + + {output_json} + --{boundary}-- + """) + assert results.text == results_body + assert outputs.json["outputs"] == { + "output_data": "test", + "output_json": { + "href": f"{out_url}/{job_id}/output_json/output.json", + "type": ContentType.APP_JSON, + }, + } def test_execute_multi_output_prefer_header_return_minimal(self): raise NotImplementedError # FIXME: implement @@ -3554,6 +3651,18 @@ def test_execute_multi_output_response_raw_reference(self): def test_execute_multi_output_response_raw_mixed(self): raise NotImplementedError # FIXME: implement + def test_execute_multi_output_response_document_defaults(self): + """ + Test ``response: document`` with default ``transmissionMode`` resolutions for literal/complex outputs. + """ + raise NotImplementedError # FIXME: implement + + def test_execute_multi_output_response_document_mixed(self): + """ + Test ``response: document`` with ``transmissionMode`` specified to force convertion of literal/complex outputs. + """ + raise NotImplementedError # FIXME: implement + # FIXME: implement other variations as well... see doc 'Execution Results' combinations diff --git a/tests/test_formats.py b/tests/test_formats.py index 26880f242..d381f61a7 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -232,7 +232,7 @@ def test_get_format(test_content_type, expected_content_type, expected_content_e [ f.ContentType.APP_OCTET_STREAM, f.ContentType.APP_FORM, - f.ContentType.MULTI_PART_FORM, + f.ContentType.MULTIPART_FORM, ] ) def test_get_format_media_type_no_extension(test_extension): @@ -289,7 +289,7 @@ def test_get_format_media_type_from_schema(test_format, expect_media_type): [ f.ContentType.APP_OCTET_STREAM, f.ContentType.APP_FORM, - f.ContentType.MULTI_PART_FORM, + f.ContentType.MULTIPART_FORM, ] ) ) diff --git a/weaver/formats.py b/weaver/formats.py index 4d516fa9e..0014bb823 100644 --- a/weaver/formats.py +++ b/weaver/formats.py @@ -116,7 +116,8 @@ class ContentType(Constants): IMAGE_GIF = "image/gif" IMAGE_PNG = "image/png" IMAGE_TIFF = "image/tiff" - MULTI_PART_FORM = "multipart/form-data" + MULTIPART_FORM = "multipart/form-data" + MULTIPART_RELATED = "multipart/related" TEXT_ENRICHED = "text/enriched" TEXT_HTML = "text/html" TEXT_PLAIN = "text/plain" @@ -447,12 +448,12 @@ class SchemaRole(Constants): ContentType.APP_DIR: "/", # force href to finish with explicit '/' to mark directory ContentType.APP_OCTET_STREAM: "", ContentType.APP_FORM: "", - ContentType.MULTI_PART_FORM: "", + ContentType.MULTIPART_FORM: "", } _CONTENT_TYPE_EXCLUDE = [ ContentType.APP_OCTET_STREAM, ContentType.APP_FORM, - ContentType.MULTI_PART_FORM, + ContentType.MULTIPART_FORM, ] _EXTENSION_CONTENT_TYPES_OVERRIDES = { ".text": ContentType.TEXT_PLAIN, # common alias to .txt, especially when using format query diff --git a/weaver/wps_restapi/swagger_definitions.py b/weaver/wps_restapi/swagger_definitions.py index 9d8d085b5..11b46305d 100644 --- a/weaver/wps_restapi/swagger_definitions.py +++ b/weaver/wps_restapi/swagger_definitions.py @@ -766,8 +766,8 @@ class NoContent(ExtendedMappingSchema): class FileUploadHeaders(RequestHeaders): # MUST be multipart for upload content_type = ContentTypeHeader( - example=f"{ContentType.MULTI_PART_FORM}; boundary=43003e2f205a180ace9cd34d98f911ff", - default=ContentType.MULTI_PART_FORM, + example=f"{ContentType.MULTIPART_FORM}; boundary=43003e2f205a180ace9cd34d98f911ff", + default=ContentType.MULTIPART_FORM, description="Desired Content-Type of the file being uploaded.", missing=required) content_length = ContentLengthHeader(description="Uploaded file contents size in bytes.") content_disposition = ContentDispositionHeader(example="form-data; name=\"file\"; filename=\"desired-name.ext\"", @@ -7359,7 +7359,7 @@ class VaultUploadBody(ExtendedSchemaNode): schema_type = String description = "Multipart file contents for upload to the vault." examples = { - ContentType.MULTI_PART_FORM: { + ContentType.MULTIPART_FORM: { "summary": "Upload JSON file to vault as multipart content.", "value": EXAMPLES["vault_file_upload.txt"], }