From 7b91813a97dd21d86541a5fc515e7932855fe70f Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 20 Oct 2026 10:24:55 -0500 Subject: [PATCH 01/12] feat: generated /v1 client and job types from the tool's schemas - src/app/api/v1: typescript-angular client generated from mmli-backend's /v1 OpenAPI (snapshot from PR #148, unmerged). openapi-generator 7.24.0 via openapitools.v1.json: 6.6.0 types FastAPI's OpenAPI 3.1 parameters as any. - npm run schemas / schemas:sync: ToolInput and ToolResults generated from the tool's input and output JSON Schemas (json-schema-to-typescript 16.0.0); CI checks they are current. The template's schemas are job-template's sample. --- .github/workflows/e2e.yml | 3 + openapi/mmli-backend-v1.openapi.json | 645 ++++++++++++++++++ openapitools.v1.json | 7 + package-lock.json | 148 +++- package.json | 5 + schemas/input.schema.json | 33 + schemas/output.schema.json | 37 + scripts/tool-schemas.mjs | 91 +++ src/app/api/tool-schemas.ts | 40 ++ src/app/api/v1/.gitignore | 4 + src/app/api/v1/.openapi-generator-ignore | 23 + src/app/api/v1/.openapi-generator/FILES | 21 + src/app/api/v1/.openapi-generator/VERSION | 1 + src/app/api/v1/README.md | 185 +++++ src/app/api/v1/api.base.service.ts | 96 +++ src/app/api/v1/api.module.ts | 30 + src/app/api/v1/api/api.ts | 5 + src/app/api/v1/api/discovery.service.ts | 313 +++++++++ src/app/api/v1/api/jobs.service.ts | 419 ++++++++++++ src/app/api/v1/configuration.ts | 185 +++++ src/app/api/v1/encoder.ts | 35 + src/app/api/v1/git_push.sh | 57 ++ src/app/api/v1/index.ts | 7 + src/app/api/v1/model/hTTPValidationError.ts | 16 + src/app/api/v1/model/locationInner.ts | 14 + src/app/api/v1/model/models.ts | 4 + src/app/api/v1/model/toolInputDocument.ts | 21 + src/app/api/v1/model/validationError.ts | 18 + src/app/api/v1/param.ts | 69 ++ src/app/api/v1/provide-api.ts | 15 + src/app/api/v1/query.params.ts | 160 +++++ src/app/api/v1/variables.ts | 9 + src/stories/fixtures/job-result--default.json | 3 - src/stories/fixtures/job-result--null.json | 1 - .../fixtures/job-status--completed.json | 15 - src/stories/fixtures/job-status--error.json | 15 - .../fixtures/job-status--processing.json | 15 - src/stories/fixtures/job-status--queued.json | 15 - 38 files changed, 2713 insertions(+), 67 deletions(-) create mode 100644 openapi/mmli-backend-v1.openapi.json create mode 100644 openapitools.v1.json create mode 100644 schemas/input.schema.json create mode 100644 schemas/output.schema.json create mode 100644 scripts/tool-schemas.mjs create mode 100644 src/app/api/tool-schemas.ts create mode 100644 src/app/api/v1/.gitignore create mode 100644 src/app/api/v1/.openapi-generator-ignore create mode 100644 src/app/api/v1/.openapi-generator/FILES create mode 100644 src/app/api/v1/.openapi-generator/VERSION create mode 100644 src/app/api/v1/README.md create mode 100644 src/app/api/v1/api.base.service.ts create mode 100644 src/app/api/v1/api.module.ts create mode 100644 src/app/api/v1/api/api.ts create mode 100644 src/app/api/v1/api/discovery.service.ts create mode 100644 src/app/api/v1/api/jobs.service.ts create mode 100644 src/app/api/v1/configuration.ts create mode 100644 src/app/api/v1/encoder.ts create mode 100644 src/app/api/v1/git_push.sh create mode 100644 src/app/api/v1/index.ts create mode 100644 src/app/api/v1/model/hTTPValidationError.ts create mode 100644 src/app/api/v1/model/locationInner.ts create mode 100644 src/app/api/v1/model/models.ts create mode 100644 src/app/api/v1/model/toolInputDocument.ts create mode 100644 src/app/api/v1/model/validationError.ts create mode 100644 src/app/api/v1/param.ts create mode 100644 src/app/api/v1/provide-api.ts create mode 100644 src/app/api/v1/query.params.ts create mode 100644 src/app/api/v1/variables.ts delete mode 100644 src/stories/fixtures/job-result--default.json delete mode 100644 src/stories/fixtures/job-result--null.json delete mode 100644 src/stories/fixtures/job-status--completed.json delete mode 100644 src/stories/fixtures/job-status--error.json delete mode 100644 src/stories/fixtures/job-status--processing.json delete mode 100644 src/stories/fixtures/job-status--queued.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6efa5c5..4634690 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,6 +29,9 @@ jobs: - name: app:sync tests run: npm run test:scripts + - name: Job types are up to date with schemas/ + run: npm run schemas -- --check + - name: Unit tests run: npx ng test --watch=false --browsers=ChromeHeadless diff --git a/openapi/mmli-backend-v1.openapi.json b/openapi/mmli-backend-v1.openapi.json new file mode 100644 index 0000000..eda4438 --- /dev/null +++ b/openapi/mmli-backend-v1.openapi.json @@ -0,0 +1,645 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AlphaSynthesis Tools API", + "description": "\nA self-describing API for running the AlphaSynthesis computational tools.\n\nStart at `/v1/tools` to see what is available, `/v1/tools/{tool}/input-schema` for what\na tool accepts, then `POST /v1/tools/{tool}/jobs` to run it. Job responses carry a\n`links` object, so clients need never construct a URL.\n\nNo authentication. A job is reachable by anyone holding its id, which is an unguessable\nUUIDv4; see `/v1/service-info` for the access model.\n", + "version": "1.0.0" + }, + "paths": { + "/service-info": { + "get": { + "tags": [ + "Discovery" + ], + "summary": "Describe this service", + "operationId": "service_info_service_info_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response Service Info Service Info Get" + } + } + } + } + } + } + }, + "/tools": { + "get": { + "tags": [ + "Discovery" + ], + "summary": "List every available tool", + "description": "Return the catalog.\n\nSubjob types created by a parent job's coordinator are omitted: they cannot be\nsubmitted directly, so listing them as runnable would be misleading.", + "operationId": "list_tools_tools_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response List Tools Tools Get" + } + } + } + } + } + } + }, + "/tools/{tool}": { + "get": { + "tags": [ + "Discovery" + ], + "summary": "Describe one tool", + "operationId": "get_tool_tools__tool__get", + "parameters": [ + { + "name": "tool", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Tool" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response Get Tool Tools Tool Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tools/{tool}/input-schema": { + "get": { + "tags": [ + "Discovery" + ], + "summary": "JSON Schema describing this tool's inputs", + "description": "Return the tool's JSON Schema 2020-12 document.\n\nSelf-contained by construction: every $ref resolves within the document, so it can\nbe handed straight to a validator or embedded in a tool definition.", + "operationId": "get_input_schema_tools__tool__input_schema_get", + "parameters": [ + { + "name": "tool", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Tool" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response Get Input Schema Tools Tool Input Schema Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tools/{tool}/output-schema": { + "get": { + "tags": [ + "Discovery" + ], + "summary": "JSON Schema describing this tool's results", + "description": "Return the JSON Schema 2020-12 document that this tool's results match.\n\nPublished only by tools on the generic job contract, whose results are served\nexactly as the container wrote them. Other tools' results are reshaped per tool.", + "operationId": "get_output_schema_tools__tool__output_schema_get", + "parameters": [ + { + "name": "tool", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Tool" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response Get Output Schema Tools Tool Output Schema Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tools/{tool}/jobs": { + "post": { + "tags": [ + "Jobs" + ], + "summary": "Submit a job", + "description": "Submit a job, as JSON or as multipart with files.\n\nJSON: Content-Type: application/json, body is the tool's input document\nMultipart: an `inputs` part holding that same document as JSON text, plus any\n number of `files` parts\n\nMultipart exists because several tools need their input files present before the\njob starts. The legacy flow required uploading first to learn a job id and then\nsubmitting with it, which is what made client-chosen ids necessary. Here the server\nassigns the id and writes the files itself, so neither is needed.", + "operationId": "submit_job_tools__tool__jobs_post", + "parameters": [ + { + "name": "tool", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Tool" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "name": "X-Notify-Email", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Where to send a message when the job finishes. Jobs here run for minutes to hours, so without it there is nothing to tell you it is done. Sent as a header so the request body stays exactly the published input schema.", + "title": "X-Notify-Email" + }, + "description": "Optional. Where to send a message when the job finishes. Jobs here run for minutes to hours, so without it there is nothing to tell you it is done. Sent as a header so the request body stays exactly the published input schema." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Tool input document", + "description": "The tool's input document, exactly as published at /v1/tools/{tool}/input-schema. It is validated against that schema before the job starts, and a rejection names the offending field. No wrapper object: the body is the document itself.\n\nMost tools take an object; a few, such as somn, take an array. The example below is for novostoic-optstoic -- fetch the tool's own schema for anything else.", + "oneOf": [ + { + "type": "object", + "title": "Object input" + }, + { + "type": "array", + "title": "Array input", + "items": { + "type": "object" + } + } + ], + "example": { + "primary_precursor": "MNXM1137670", + "target_molecule": "MNXM26" + } + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_submit_job_tools__tool__jobs_post" + } + } + } + }, + "responses": { + "201": { + "description": "Job accepted and queued.", + "content": { + "application/json": { + "schema": {}, + "example": { + "job_id": "4b3a85ee4efa42c3b5e5c1641e0588f4", + "tool": "novostoic-optstoic", + "status": "queued", + "submitted_at": "2026-08-10T01:40:32Z", + "inputs": { + "primary_precursor": "MNXM1137670", + "target_molecule": "MNXM26" + }, + "provenance": { + "image": "moleculemaker/novostoic", + "command": "python ./novostoic-job.py optstoic" + }, + "links": { + "self": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4", + "results": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/results", + "artifacts": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/artifacts", + "tool": "/v1/tools/novostoic-optstoic", + "cancel": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/cancel" + } + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/jobs/{job_id}": { + "get": { + "tags": [ + "Jobs" + ], + "summary": "Get a job", + "operationId": "get_job_jobs__job_id__get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "The job.", + "content": { + "application/json": { + "schema": {}, + "example": { + "job_id": "4b3a85ee4efa42c3b5e5c1641e0588f4", + "tool": "novostoic-optstoic", + "status": "queued", + "submitted_at": "2026-08-10T01:40:32Z", + "inputs": { + "primary_precursor": "MNXM1137670", + "target_molecule": "MNXM26" + }, + "provenance": { + "image": "moleculemaker/novostoic", + "command": "python ./novostoic-job.py optstoic" + }, + "links": { + "self": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4", + "results": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/results", + "artifacts": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/artifacts", + "tool": "/v1/tools/novostoic-optstoic", + "cancel": "/v1/jobs/4b3a85ee4efa42c3b5e5c1641e0588f4/cancel" + } + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/jobs/{job_id}/results": { + "get": { + "tags": [ + "Jobs" + ], + "summary": "Get a job's results", + "description": "Return post-processed results once the job has finished.\n\nStatus codes carry the meaning, so a polling client need not parse the body:\n\n 200 finished, results attached\n 409 still queued or running (Retry-After), or finished without usable results\n 404 no such job\n\nThe legacy endpoint answers 200 with a null body for \"running\", \"finished with\nnothing\" and \"no such job\" alike, which a script cannot tell apart.", + "operationId": "get_results_jobs__job_id__results_get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/jobs/{job_id}/artifacts": { + "get": { + "tags": [ + "Jobs" + ], + "summary": "List the files a job produced", + "description": "List raw output files.\n\nLogs and status-marker files are excluded unless `?include=logs`. Their content is\nwhatever the container printed, which can carry internal paths and configuration,\nand anyone holding a job id can read them.", + "operationId": "list_artifacts_jobs__job_id__artifacts_get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + }, + { + "name": "include", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Include" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/jobs/{job_id}/artifacts/{artifact_path}": { + "get": { + "tags": [ + "Jobs" + ], + "summary": "Download one output file", + "operationId": "get_artifact_jobs__job_id__artifacts__artifact_path__get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + }, + { + "name": "artifact_path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Artifact Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/jobs/{job_id}/cancel": { + "post": { + "tags": [ + "Jobs" + ], + "summary": "Cancel a running job", + "description": "Stop a job and mark it canceled.\n\nIdempotent: canceling an already-canceled job succeeds. A job that has already\nfinished cannot be canceled, and says so rather than pretending.", + "operationId": "cancel_job_jobs__job_id__cancel_post", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Body_submit_job_tools__tool__jobs_post": { + "properties": { + "inputs": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Inputs" + }, + "files": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "binary" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Files" + } + }, + "type": "object", + "title": "Body_submit_job_tools__tool__jobs_post" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/openapitools.v1.json b/openapitools.v1.json new file mode 100644 index 0000000..936223a --- /dev/null +++ b/openapitools.v1.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.24.0" + } +} diff --git a/package-lock.json b/package-lock.json index 73a0d78..8d99328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@types/uuid": "^10.0.0", "chromedriver": "130.0.1", "jasmine-core": "~4.5.0", + "json-schema-to-typescript": "16.0.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage": "~2.2.0", @@ -969,6 +970,24 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@arr/every": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@arr/every/-/every-1.0.1.tgz", @@ -4166,6 +4185,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -7664,6 +7690,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.26", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", + "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -14079,6 +14112,99 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/json-schema-to-typescript": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-16.0.0.tgz", + "integrity": "sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.9.3", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.25", + "js-yaml": "^5.2.3", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "prettier": "^3.9.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/json-schema-to-typescript/node_modules/argparse": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz", + "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/json-schema-to-typescript/node_modules/js-yaml": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz", + "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-schema-to-typescript/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/json-schema-to-typescript/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -15087,9 +15213,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -18337,6 +18463,22 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/primeicons": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", diff --git a/package.json b/package.json index 7b472d4..e79892c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "openapi:live": "npm run openapi:sync && npm run openapi", "openapi:sync:local": "curl http://localhost:8080/openapi.json > openapi/mmli-backend.openapi.json && cat openapi/mmli-backend.openapi.json", "openapi:live:local": "npm run openapi:sync:local && npm run openapi", + "openapi:v1": "openapi-generator-cli --openapitools openapitools.v1.json generate --skip-validate-spec -i openapi/mmli-backend-v1.openapi.json -g typescript-angular -o src/app/api/v1", + "openapi:v1:sync": "curl -sf https://mmli.fastapi.staging.mmli2.ncsa.illinois.edu/v1/openapi.json -o openapi/mmli-backend-v1.openapi.json && npm run openapi:v1", + "schemas": "node scripts/tool-schemas.mjs", + "schemas:sync": "node scripts/tool-schemas.mjs --sync", "storybook": "ng run app:storybook", "build-storybook": "ng run app:build-storybook", "app:sync": "node scripts/app-sync.mjs", @@ -59,6 +63,7 @@ "@types/uuid": "^10.0.0", "chromedriver": "130.0.1", "jasmine-core": "~4.5.0", + "json-schema-to-typescript": "16.0.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage": "~2.2.0", diff --git a/schemas/input.schema.json b/schemas/input.schema.json new file mode 100644 index 0000000..4dbc229 --- /dev/null +++ b/schemas/input.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Sample tool input", + "description": "Protein sequences to measure.", + "type": "object", + "required": ["sequences"], + "additionalProperties": false, + "properties": { + "sequences": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "required": ["id", "sequence"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "maxLength": 100, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._ -]*$", + "description": "Must start with a letter or digit, so it cannot be read as a spreadsheet formula when written to CSV." + }, + "sequence": { + "type": "string", + "pattern": "^[ACDEFGHIKLMNPQRSTVWYacdefghiklmnpqrstvwy]+$", + "description": "One-letter amino acid codes; the 20 standard residues only." + } + } + } + } + } +} diff --git a/schemas/output.schema.json b/schemas/output.schema.json new file mode 100644 index 0000000..bc8cc60 --- /dev/null +++ b/schemas/output.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Sample tool results", + "description": "Shape of results.json. The frontend's result types are generated from this schema.", + "type": "object", + "required": ["rows", "artifacts"], + "additionalProperties": false, + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "length", "molecular_weight_da"], + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "length": {"type": "integer", "minimum": 1}, + "molecular_weight_da": {"type": "number"} + } + } + }, + "artifacts": { + "description": "Files in the output directory that the backend serves by name. Every tool's output schema keeps this property as-is.", + "type": "array", + "items": { + "type": "object", + "required": ["name", "media_type"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "pattern": "^[A-Za-z0-9._-]+$"}, + "media_type": {"type": "string"}, + "description": {"type": "string"} + } + } + } + } +} diff --git a/scripts/tool-schemas.mjs b/scripts/tool-schemas.mjs new file mode 100644 index 0000000..610e2d5 --- /dev/null +++ b/scripts/tool-schemas.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// TypeScript types for this app's job input and results, from the tool's JSON Schemas. +// +// npm run schemas regenerate src/app/api/tool-schemas.ts from schemas/ +// npm run schemas -- --check exit 1 if it is out of date (CI runs this) +// npm run schemas:sync fetch the schemas for app.yaml's job_type from the +// staging backend first (--api for another one) +// +// The backend is the source of truth for both schemas (mmli-backend +// app/schemas/.input.json and .output.json); schemas/ holds the copy this app +// was built against, so a build never depends on the network. The template's copies +// are the sample tool's, from moleculemaker/job-template. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compile } from 'json-schema-to-typescript'; +import { parse } from 'yaml'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const STAGING_API = 'https://mmli.fastapi.staging.mmli2.ncsa.illinois.edu/v1'; +const SCHEMAS = { input: 'schemas/input.schema.json', output: 'schemas/output.schema.json' }; +const OUTPUT = 'src/app/api/tool-schemas.ts'; + +const read = (path) => readFileSync(join(ROOT, path), 'utf8'); + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return response.json(); +} + +// The tool's descriptor links to both schemas; a tool without an output schema is not +// on the backend's generic job contract, and this flow does not support it. +async function sync(api) { + const tool = parse(read('app.yaml')).job_type; + if (!tool) throw new Error('app.yaml has no job_type: set it to the tool registered in mmli-backend'); + const { links } = await fetchJson(`${api}/tools/${tool}`); + if (!links.input_schema || !links.output_schema) { + throw new Error(`${tool} publishes no ${links.input_schema ? 'output' : 'input'} schema`); + } + for (const [kind, link] of [['input', links.input_schema], ['output', links.output_schema]]) { + // $id names the host it was served from; the copy is the same document wherever it + // came from. + const { $id, ...schema } = await fetchJson(link); + writeFileSync(join(ROOT, SCHEMAS[kind]), JSON.stringify(schema, null, 2) + '\n'); + } +} + +// The interface names are fixed, so app code doesn't change with the tool: compile() +// would otherwise name each one after its schema's title. Item counts stay out of the +// types (they would become tuples); the backend enforces them. +export async function generate(readSchema = (path) => JSON.parse(read(path))) { + const options = { bannerComment: '', additionalProperties: false, ignoreMinAndMaxItems: true }; + const named = (schema, title) => ({ ...schema, title }); + const input = await compile(named(readSchema(SCHEMAS.input), 'ToolInput'), 'ToolInput', options); + const output = await compile(named(readSchema(SCHEMAS.output), 'ToolResults'), 'ToolResults', options); + return [ + '// Generated by `npm run schemas` from schemas/. Do not edit.', + '', + input.trim(), + '', + output.trim(), + '', + ].join('\n'); +} + +async function main(args) { + if (args.includes('--sync')) { + const at = args.indexOf('--api'); + await sync(at >= 0 ? args[at + 1] : STAGING_API); + } + const generated = await generate(); + if (args.includes('--check')) { + let current = ''; + try { current = read(OUTPUT); } catch { /* missing counts as out of date */ } + if (current !== generated) { + console.error(`${OUTPUT} is out of date with schemas/. Run \`npm run schemas\`.`); + process.exit(1); + } + return; + } + writeFileSync(join(ROOT, OUTPUT), generated); + console.log(`wrote ${OUTPUT}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/src/app/api/tool-schemas.ts b/src/app/api/tool-schemas.ts new file mode 100644 index 0000000..05ef443 --- /dev/null +++ b/src/app/api/tool-schemas.ts @@ -0,0 +1,40 @@ +// Generated by `npm run schemas` from schemas/. Do not edit. + +/** + * Protein sequences to measure. + */ +export interface ToolInput { + /** + * @minItems 1 + * @maxItems 100 + */ + sequences: { + /** + * Must start with a letter or digit, so it cannot be read as a spreadsheet formula when written to CSV. + */ + id: string; + /** + * One-letter amino acid codes; the 20 standard residues only. + */ + sequence: string; + }[]; +} + +/** + * Shape of results.json. The frontend's result types are generated from this schema. + */ +export interface ToolResults { + rows: { + id: string; + length: number; + molecular_weight_da: number; + }[]; + /** + * Files in the output directory that the backend serves by name. Every tool's output schema keeps this property as-is. + */ + artifacts: { + name: string; + media_type: string; + description?: string; + }[]; +} diff --git a/src/app/api/v1/.gitignore b/src/app/api/v1/.gitignore new file mode 100644 index 0000000..149b576 --- /dev/null +++ b/src/app/api/v1/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/src/app/api/v1/.openapi-generator-ignore b/src/app/api/v1/.openapi-generator-ignore new file mode 100644 index 0000000..7484ee5 --- /dev/null +++ b/src/app/api/v1/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/src/app/api/v1/.openapi-generator/FILES b/src/app/api/v1/.openapi-generator/FILES new file mode 100644 index 0000000..e4ec8b2 --- /dev/null +++ b/src/app/api/v1/.openapi-generator/FILES @@ -0,0 +1,21 @@ +.gitignore +.openapi-generator-ignore +README.md +api.base.service.ts +api.module.ts +api/api.ts +api/discovery.service.ts +api/jobs.service.ts +configuration.ts +encoder.ts +git_push.sh +index.ts +model/hTTPValidationError.ts +model/locationInner.ts +model/models.ts +model/toolInputDocument.ts +model/validationError.ts +param.ts +provide-api.ts +query.params.ts +variables.ts diff --git a/src/app/api/v1/.openapi-generator/VERSION b/src/app/api/v1/.openapi-generator/VERSION new file mode 100644 index 0000000..0783219 --- /dev/null +++ b/src/app/api/v1/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.24.0 diff --git a/src/app/api/v1/README.md b/src/app/api/v1/README.md new file mode 100644 index 0000000..2148e1b --- /dev/null +++ b/src/app/api/v1/README.md @@ -0,0 +1,185 @@ +# @ + + A self-describing API for running the AlphaSynthesis computational tools. Start at `/v1/tools` to see what is available, `/v1/tools/{tool}/input-schema` for what a tool accepts, then `POST /v1/tools/{tool}/jobs` to run it. Job responses carry a `links` object, so clients need never construct a URL. No authentication. A job is reachable by anyone holding its id, which is an unguessable UUIDv4; see `/v1/service-info` for the access model. + +The version of the OpenAPI document: 1.0.0 + +## Building + +To install the required dependencies and to build the typescript sources run: + +```console +npm install +npm run build +``` + +## Publishing + +First build the package then run `npm publish dist` (don't forget to specify the `dist` folder!) + +## Consuming + +Navigate to the folder of your consuming project and run one of next commands. + +_published:_ + +```console +npm install @ --save +``` + +_without publishing (not recommended):_ + +```console +npm install PATH_TO_GENERATED_PACKAGE/dist.tgz --save +``` + +_It's important to take the tgz file, otherwise you'll get trouble with links on windows_ + +_using `npm link`:_ + +In PATH_TO_GENERATED_PACKAGE/dist: + +```console +npm link +``` + +In your project: + +```console +npm link +``` + +__Note for Windows users:__ The Angular CLI has troubles to use linked npm packages. +Please refer to this issue for a solution / workaround. +Published packages are not effected by this issue. + +### General usage + +In your Angular project: + +```typescript + +import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideApi } from ''; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... + provideHttpClient(), + provideApi() + ], +}; +``` + +**NOTE** +If you're still using `AppModule` and haven't [migrated](https://angular.dev/reference/migrations/standalone) yet, you can still import an Angular module: +```typescript +import { ApiModule } from ''; +``` + +If different from the generated base path, during app bootstrap, you can provide the base path to your service. + +```typescript +import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideApi } from ''; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... + provideHttpClient(), + provideApi('http://localhost:9999') + ], +}; +``` + +```typescript +// with a custom configuration +import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideApi } from ''; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... + provideHttpClient(), + provideApi({ + withCredentials: true, + username: 'user', + password: 'password' + }) + ], +}; +``` + +```typescript +// with factory building a custom configuration +import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideApi, Configuration } from ''; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... + provideHttpClient(), + { + provide: Configuration, + useFactory: (authService: AuthService) => new Configuration({ + basePath: 'http://localhost:9999', + withCredentials: true, + username: authService.getUsername(), + password: authService.getPassword(), + }), + deps: [AuthService], + multi: false + } + ], +}; +``` + +### Using multiple OpenAPI files / APIs + +In order to use multiple APIs generated from different OpenAPI files, +you can create an alias name when importing the modules +in order to avoid naming conflicts: + +```typescript +import { provideApi as provideUserApi } from 'my-user-api-path'; +import { provideApi as provideAdminApi } from 'my-admin-api-path'; +import { HttpClientModule } from '@angular/common/http'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... + provideHttpClient(), + provideUserApi(environment.basePath), + provideAdminApi(environment.basePath), + ], +}; +``` + +### Customizing path parameter encoding + +Without further customization, only [path-parameters][parameter-locations-url] of [style][style-values-url] 'simple' +and Dates for format 'date-time' are encoded correctly. + +Other styles (e.g. "matrix") are not that easy to encode +and thus are best delegated to other libraries (e.g.: [@honoluluhenk/http-param-expander]). + +To implement your own parameter encoding (or call another library), +pass an arrow-function or method-reference to the `encodeParam` property of the Configuration-object +(see [General Usage](#general-usage) above). + +Example value for use in your Configuration-Provider: + +```typescript +new Configuration({ + encodeParam: (param: Param) => myFancyParamEncoder(param), +}) +``` + +[parameter-locations-url]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameter-locations +[style-values-url]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values +[@honoluluhenk/http-param-expander]: https://www.npmjs.com/package/@honoluluhenk/http-param-expander diff --git a/src/app/api/v1/api.base.service.ts b/src/app/api/v1/api.base.service.ts new file mode 100644 index 0000000..f15c9a3 --- /dev/null +++ b/src/app/api/v1/api.base.service.ts @@ -0,0 +1,96 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders, HttpParams, HttpParameterCodec } from '@angular/common/http'; +import { CustomHttpParameterCodec } from './encoder'; +import { Configuration } from './configuration'; +import { OpenApiHttpParams, QueryParamStyle, concatHttpParamsObject} from './query.params'; + +export class BaseService { + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration: Configuration; + public encoder: HttpParameterCodec; + + constructor(basePath?: string|string[], configuration?: Configuration) { + this.configuration = configuration || new Configuration(); + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + protected canConsumeForm(consumes: string[]): boolean { + return consumes.indexOf('multipart/form-data') !== -1; + } + + protected addToHttpParams(httpParams: OpenApiHttpParams, key: string, value: any | null | undefined, paramStyle: QueryParamStyle, explode: boolean): OpenApiHttpParams { + if (value === null || value === undefined) { + return httpParams; + } + + if (paramStyle === QueryParamStyle.DeepObject) { + if (typeof value !== 'object') { + throw Error(`An object must be provided for key ${key} as it is a deep object`); + } + + return Object.keys(value as Record).reduce( + (hp, k) => hp.append(`${key}[${k}]`, value[k]), + httpParams, + ); + } else if (paramStyle === QueryParamStyle.Json) { + return httpParams.append(key, JSON.stringify(value)); + } else { + // Form-style, SpaceDelimited or PipeDelimited + + if (Object(value) !== value) { + // If it is a primitive type, add its string representation + return httpParams.append(key, value.toString()); + } else if (value instanceof Date) { + return httpParams.append(key, value.toISOString()); + } else if (Array.isArray(value) || value instanceof Set) { + // Otherwise, if it's an array or set, add each element. + const array = Array.isArray(value) ? value : Array.from(value); + if (paramStyle === QueryParamStyle.Form) { + return httpParams.set(key, array, {explode: explode, delimiter: ','}); + } else if (paramStyle === QueryParamStyle.SpaceDelimited) { + return httpParams.set(key, array, {explode: explode, delimiter: ' '}); + } else { + // PipeDelimited + return httpParams.set(key, array, {explode: explode, delimiter: '|'}); + } + } else { + // Otherwise, if it's an object, add each field. + if (paramStyle === QueryParamStyle.Form) { + if (explode) { + Object.keys(value).forEach(k => { + httpParams = this.addToHttpParams(httpParams, k, value[k], paramStyle, explode); + }); + return httpParams; + } else { + return concatHttpParamsObject(httpParams, key, value, ','); + } + } else if (paramStyle === QueryParamStyle.SpaceDelimited) { + return concatHttpParamsObject(httpParams, key, value, ' '); + } else { + // PipeDelimited + return concatHttpParamsObject(httpParams, key, value, '|'); + } + } + } + } +} diff --git a/src/app/api/v1/api.module.ts b/src/app/api/v1/api.module.ts new file mode 100644 index 0000000..58d341f --- /dev/null +++ b/src/app/api/v1/api.module.ts @@ -0,0 +1,30 @@ +import { NgModule, ModuleWithProviders, SkipSelf, Optional } from '@angular/core'; +import { Configuration } from './configuration'; +import { HttpClient } from '@angular/common/http'; + + +@NgModule({ + imports: [], + declarations: [], + exports: [], + providers: [] +}) +export class ApiModule { + public static forRoot(configurationFactory: () => Configuration): ModuleWithProviders { + return { + ngModule: ApiModule, + providers: [ { provide: Configuration, useFactory: configurationFactory } ] + }; + } + + constructor( @Optional() @SkipSelf() parentModule: ApiModule, + @Optional() http: HttpClient) { + if (parentModule) { + throw new Error('ApiModule is already loaded. Import in your base AppModule only.'); + } + if (!http) { + throw new Error('You need to import the HttpClientModule in your AppModule! \n' + + 'See also https://github.com/angular/angular/issues/20575'); + } + } +} diff --git a/src/app/api/v1/api/api.ts b/src/app/api/v1/api/api.ts new file mode 100644 index 0000000..5d1bef2 --- /dev/null +++ b/src/app/api/v1/api/api.ts @@ -0,0 +1,5 @@ +export * from './discovery.service'; +import { DiscoveryService } from './discovery.service'; +export * from './jobs.service'; +import { JobsService } from './jobs.service'; +export const APIS = [DiscoveryService, JobsService]; diff --git a/src/app/api/v1/api/discovery.service.ts b/src/app/api/v1/api/discovery.service.ts new file mode 100644 index 0000000..c8467e0 --- /dev/null +++ b/src/app/api/v1/api/discovery.service.ts @@ -0,0 +1,313 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpContext + } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { OpenApiHttpParams, QueryParamStyle } from '../query.params'; + +// @ts-ignore +import { HTTPValidationError } from '../model/hTTPValidationError'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { BaseService } from '../api.base.service'; + + + +@Injectable({ + providedIn: 'root' +}) +export class DiscoveryService extends BaseService { + + constructor(protected httpClient: HttpClient, @Optional() @Inject(BASE_PATH) basePath: string|string[], @Optional() configuration?: Configuration) { + super(basePath, configuration); + } + + /** + * JSON Schema describing this tool\'s inputs + * Return the tool\'s JSON Schema 2020-12 document. Self-contained by construction: every $ref resolves within the document, so it can be handed straight to a validator or embedded in a tool definition. + * @endpoint get /tools/{tool}/input-schema + * @param tool + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getInputSchemaToolsToolInputSchemaGet(tool: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getInputSchemaToolsToolInputSchemaGet(tool: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getInputSchemaToolsToolInputSchemaGet(tool: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getInputSchemaToolsToolInputSchemaGet(tool: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (tool === null || tool === undefined) { + throw new Error('Required parameter tool was null or undefined when calling getInputSchemaToolsToolInputSchemaGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/tools/${this.configuration.encodeParam({name: "tool", value: tool, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/input-schema`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * JSON Schema describing this tool\'s results + * Return the JSON Schema 2020-12 document that this tool\'s results match. Published only by tools on the generic job contract, whose results are served exactly as the container wrote them. Other tools\' results are reshaped per tool. + * @endpoint get /tools/{tool}/output-schema + * @param tool + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getOutputSchemaToolsToolOutputSchemaGet(tool: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getOutputSchemaToolsToolOutputSchemaGet(tool: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getOutputSchemaToolsToolOutputSchemaGet(tool: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getOutputSchemaToolsToolOutputSchemaGet(tool: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (tool === null || tool === undefined) { + throw new Error('Required parameter tool was null or undefined when calling getOutputSchemaToolsToolOutputSchemaGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/tools/${this.configuration.encodeParam({name: "tool", value: tool, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/output-schema`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Describe one tool + * @endpoint get /tools/{tool} + * @param tool + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getToolToolsToolGet(tool: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getToolToolsToolGet(tool: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getToolToolsToolGet(tool: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getToolToolsToolGet(tool: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (tool === null || tool === undefined) { + throw new Error('Required parameter tool was null or undefined when calling getToolToolsToolGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/tools/${this.configuration.encodeParam({name: "tool", value: tool, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * List every available tool + * Return the catalog. Subjob types created by a parent job\'s coordinator are omitted: they cannot be submitted directly, so listing them as runnable would be misleading. + * @endpoint get /tools + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public listToolsToolsGet(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public listToolsToolsGet(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public listToolsToolsGet(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public listToolsToolsGet(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/tools`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Describe this service + * @endpoint get /service-info + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public serviceInfoServiceInfoGet(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public serviceInfoServiceInfoGet(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public serviceInfoServiceInfoGet(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public serviceInfoServiceInfoGet(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/service-info`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + +} diff --git a/src/app/api/v1/api/jobs.service.ts b/src/app/api/v1/api/jobs.service.ts new file mode 100644 index 0000000..162699d --- /dev/null +++ b/src/app/api/v1/api/jobs.service.ts @@ -0,0 +1,419 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpContext + } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { OpenApiHttpParams, QueryParamStyle } from '../query.params'; + +// @ts-ignore +import { BodySubmitJobToolsToolJobsPost } from '../model/bodySubmitJobToolsToolJobsPost'; +// @ts-ignore +import { HTTPValidationError } from '../model/hTTPValidationError'; +// @ts-ignore +import { ToolInputDocument } from '../model/toolInputDocument'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { BaseService } from '../api.base.service'; + + + +@Injectable({ + providedIn: 'root' +}) +export class JobsService extends BaseService { + + constructor(protected httpClient: HttpClient, @Optional() @Inject(BASE_PATH) basePath: string|string[], @Optional() configuration?: Configuration) { + super(basePath, configuration); + } + + /** + * Cancel a running job + * Stop a job and mark it canceled. Idempotent: canceling an already-canceled job succeeds. A job that has already finished cannot be canceled, and says so rather than pretending. + * @endpoint post /jobs/{job_id}/cancel + * @param jobId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public cancelJobJobsJobIdCancelPost(jobId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public cancelJobJobsJobIdCancelPost(jobId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public cancelJobJobsJobIdCancelPost(jobId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public cancelJobJobsJobIdCancelPost(jobId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (jobId === null || jobId === undefined) { + throw new Error('Required parameter jobId was null or undefined when calling cancelJobJobsJobIdCancelPost.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jobs/${this.configuration.encodeParam({name: "jobId", value: jobId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/cancel`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('post', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Download one output file + * @endpoint get /jobs/{job_id}/artifacts/{artifact_path} + * @param jobId + * @param artifactPath + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getArtifactJobsJobIdArtifactsArtifactPathGet(jobId: string, artifactPath: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getArtifactJobsJobIdArtifactsArtifactPathGet(jobId: string, artifactPath: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getArtifactJobsJobIdArtifactsArtifactPathGet(jobId: string, artifactPath: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getArtifactJobsJobIdArtifactsArtifactPathGet(jobId: string, artifactPath: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (jobId === null || jobId === undefined) { + throw new Error('Required parameter jobId was null or undefined when calling getArtifactJobsJobIdArtifactsArtifactPathGet.'); + } + if (artifactPath === null || artifactPath === undefined) { + throw new Error('Required parameter artifactPath was null or undefined when calling getArtifactJobsJobIdArtifactsArtifactPathGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jobs/${this.configuration.encodeParam({name: "jobId", value: jobId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/artifacts/${this.configuration.encodeParam({name: "artifactPath", value: artifactPath, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Get a job + * @endpoint get /jobs/{job_id} + * @param jobId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getJobJobsJobIdGet(jobId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getJobJobsJobIdGet(jobId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getJobJobsJobIdGet(jobId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getJobJobsJobIdGet(jobId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (jobId === null || jobId === undefined) { + throw new Error('Required parameter jobId was null or undefined when calling getJobJobsJobIdGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jobs/${this.configuration.encodeParam({name: "jobId", value: jobId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Get a job\'s results + * Return post-processed results once the job has finished. Status codes carry the meaning, so a polling client need not parse the body: 200 finished, results attached 409 still queued or running (Retry-After), or finished without usable results 404 no such job The legacy endpoint answers 200 with a null body for \"running\", \"finished with nothing\" and \"no such job\" alike, which a script cannot tell apart. + * @endpoint get /jobs/{job_id}/results + * @param jobId + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public getResultsJobsJobIdResultsGet(jobId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getResultsJobsJobIdResultsGet(jobId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getResultsJobsJobIdResultsGet(jobId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getResultsJobsJobIdResultsGet(jobId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (jobId === null || jobId === undefined) { + throw new Error('Required parameter jobId was null or undefined when calling getResultsJobsJobIdResultsGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jobs/${this.configuration.encodeParam({name: "jobId", value: jobId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/results`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * List the files a job produced + * List raw output files. Logs and status-marker files are excluded unless `?include=logs`. Their content is whatever the container printed, which can carry internal paths and configuration, and anyone holding a job id can read them. + * @endpoint get /jobs/{job_id}/artifacts + * @param jobId + * @param include + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public listArtifactsJobsJobIdArtifactsGet(jobId: string, include?: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public listArtifactsJobsJobIdArtifactsGet(jobId: string, include?: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public listArtifactsJobsJobIdArtifactsGet(jobId: string, include?: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public listArtifactsJobsJobIdArtifactsGet(jobId: string, include?: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (jobId === null || jobId === undefined) { + throw new Error('Required parameter jobId was null or undefined when calling listArtifactsJobsJobIdArtifactsGet.'); + } + + let localVarQueryParameters = new OpenApiHttpParams(this.encoder); + + localVarQueryParameters = this.addToHttpParams( + localVarQueryParameters, + 'include', + include, + QueryParamStyle.Form, + true, + ); + + + let localVarHeaders = this.defaultHeaders; + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jobs/${this.configuration.encodeParam({name: "jobId", value: jobId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/artifacts`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('get', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + params: localVarQueryParameters.toHttpParams(), + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + + /** + * Submit a job + * Submit a job, as JSON or as multipart with files. JSON: Content-Type: application/json, body is the tool\'s input document Multipart: an `inputs` part holding that same document as JSON text, plus any number of `files` parts Multipart exists because several tools need their input files present before the job starts. The legacy flow required uploading first to learn a job id and then submitting with it, which is what made client-chosen ids necessary. Here the server assigns the id and writes the files itself, so neither is needed. + * @endpoint post /tools/{tool}/jobs + * @param tool + * @param idempotencyKey + * @param xNotifyEmail Optional. Where to send a message when the job finishes. Jobs here run for minutes to hours, so without it there is nothing to tell you it is done. Sent as a header so the request body stays exactly the published input schema. + * @param objectArrayobject + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + * @param options additional options + */ + public submitJobToolsToolJobsPost(tool: string, idempotencyKey?: string, xNotifyEmail?: string, objectArrayobject?: object | Array, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public submitJobToolsToolJobsPost(tool: string, idempotencyKey?: string, xNotifyEmail?: string, objectArrayobject?: object | Array, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public submitJobToolsToolJobsPost(tool: string, idempotencyKey?: string, xNotifyEmail?: string, objectArrayobject?: object | Array, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public submitJobToolsToolJobsPost(tool: string, idempotencyKey?: string, xNotifyEmail?: string, objectArrayobject?: object | Array, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (tool === null || tool === undefined) { + throw new Error('Required parameter tool was null or undefined when calling submitJobToolsToolJobsPost.'); + } + + let localVarHeaders = this.defaultHeaders; + if (idempotencyKey !== undefined && idempotencyKey !== null) { + localVarHeaders = localVarHeaders.set('Idempotency-Key', String(idempotencyKey)); + } + if (xNotifyEmail !== undefined && xNotifyEmail !== null) { + localVarHeaders = localVarHeaders.set('X-Notify-Email', String(xNotifyEmail)); + } + + const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([ + 'application/json' + ]); + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + const localVarHttpContext: HttpContext = options?.context ?? new HttpContext(); + + const localVarTransferCache: boolean = options?.transferCache ?? true; + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json', + 'multipart/form-data' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/tools/${this.configuration.encodeParam({name: "tool", value: tool, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/jobs`; + const { basePath, withCredentials } = this.configuration; + return this.httpClient.request('post', `${basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: objectArrayobject, + responseType: responseType_, + ...(withCredentials ? { withCredentials } : {}), + headers: localVarHeaders, + observe: observe, + ...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}), + reportProgress: reportProgress + } + ); + } + +} diff --git a/src/app/api/v1/configuration.ts b/src/app/api/v1/configuration.ts new file mode 100644 index 0000000..eb876c1 --- /dev/null +++ b/src/app/api/v1/configuration.ts @@ -0,0 +1,185 @@ +import { HttpHeaders, HttpParameterCodec } from '@angular/common/http'; +import { Param } from './param'; +import { OpenApiHttpParams } from './query.params'; + +export interface ConfigurationParameters { + /** + * @deprecated Since 5.0. Use credentials instead + */ + apiKeys?: {[ key: string ]: string}; + username?: string; + password?: string; + /** + * @deprecated Since 5.0. Use credentials instead + */ + accessToken?: string | (() => string); + basePath?: string; + withCredentials?: boolean; + /** + * Takes care of encoding query- and form-parameters. + */ + encoder?: HttpParameterCodec; + /** + * Override the default method for encoding path parameters in various + * styles. + *

+ * See {@link README.md} for more details + *

+ */ + encodeParam?: (param: Param) => string; + /** + * The keys are the names in the securitySchemes section of the OpenAPI + * document. They should map to the value used for authentication + * minus any standard prefixes such as 'Basic' or 'Bearer'. + */ + credentials?: {[ key: string ]: string | (() => string | undefined)}; +} + +export class Configuration { + /** + * @deprecated Since 5.0. Use credentials instead + */ + apiKeys?: {[ key: string ]: string}; + username?: string; + password?: string; + /** + * @deprecated Since 5.0. Use credentials instead + */ + accessToken?: string | (() => string); + basePath?: string; + withCredentials?: boolean; + /** + * Takes care of encoding query- and form-parameters. + */ + encoder?: HttpParameterCodec; + /** + * Encoding of various path parameter + * styles. + *

+ * See {@link README.md} for more details + *

+ */ + encodeParam: (param: Param) => string; + /** + * The keys are the names in the securitySchemes section of the OpenAPI + * document. They should map to the value used for authentication + * minus any standard prefixes such as 'Basic' or 'Bearer'. + */ + credentials: {[ key: string ]: string | (() => string | undefined)}; + +constructor({ accessToken, apiKeys, basePath, credentials, encodeParam, encoder, password, username, withCredentials }: ConfigurationParameters = {}) { + if (apiKeys) { + this.apiKeys = apiKeys; + } + if (username !== undefined) { + this.username = username; + } + if (password !== undefined) { + this.password = password; + } + if (accessToken !== undefined) { + this.accessToken = accessToken; + } + if (basePath !== undefined) { + this.basePath = basePath; + } + if (withCredentials !== undefined) { + this.withCredentials = withCredentials; + } + if (encoder) { + this.encoder = encoder; + } + this.encodeParam = encodeParam ?? (param => this.defaultEncodeParam(param)); + this.credentials = credentials ?? {}; + } + + /** + * Select the correct content-type to use for a request. + * Uses {@link Configuration#isJsonMime} to determine the correct content-type. + * If no content type is found return the first found type if the contentTypes is not empty + * @param contentTypes - the array of content types that are available for selection + * @returns the selected content-type or undefined if no selection could be made. + */ + public selectHeaderContentType (contentTypes: string[]): string | undefined { + if (contentTypes.length === 0) { + return undefined; + } + + const type = contentTypes.find((x: string) => this.isJsonMime(x)); + if (type === undefined) { + return contentTypes[0]; + } + return type; + } + + /** + * Select the correct accept content-type to use for a request. + * Uses {@link Configuration#isJsonMime} to determine the correct accept content-type. + * If no content type is found return the first found type if the contentTypes is not empty + * @param accepts - the array of content types that are available for selection. + * @returns the selected content-type or undefined if no selection could be made. + */ + public selectHeaderAccept(accepts: string[]): string | undefined { + if (accepts.length === 0) { + return undefined; + } + + const type = accepts.find((x: string) => this.isJsonMime(x)); + if (type === undefined) { + return accepts[0]; + } + return type; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = /^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$/i; + return mime !== null && jsonMime.test(mime); + } + + public lookupCredential(key: string): string | undefined { + const value = this.credentials[key]; + return typeof value === 'function' + ? value() + : value; + } + + public addCredentialToHeaders(credentialKey: string, headerName: string, headers: HttpHeaders, prefix?: string): HttpHeaders { + const value = this.lookupCredential(credentialKey); + return value + ? headers.set(headerName, (prefix ?? '') + value) + : headers; + } + + public addCredentialToQuery(credentialKey: string, paramName: string, query: OpenApiHttpParams): OpenApiHttpParams { + const value = this.lookupCredential(credentialKey); + return value + ? query.set(paramName, value) + : query; + } + + private defaultEncodeParam(param: Param): string { + // This implementation exists as fallback for missing configuration + // and for backwards compatibility to older typescript-angular generator versions. + // It only works for the 'simple' parameter style. + // Date-handling only works for the 'date-time' format. + // All other styles and Date-formats are probably handled incorrectly. + // + // But: if that's all you need (i.e.: the most common use-case): no need for customization! + + const value = param.dataFormat === 'date-time' && param.value instanceof Date + ? (param.value as Date).toISOString() + : param.value; + + return encodeURIComponent(String(value)); + } +} diff --git a/src/app/api/v1/encoder.ts b/src/app/api/v1/encoder.ts new file mode 100644 index 0000000..af45235 --- /dev/null +++ b/src/app/api/v1/encoder.ts @@ -0,0 +1,35 @@ +import { HttpParameterCodec } from '@angular/common/http'; + +/** + * Custom HttpParameterCodec + * Workaround for https://github.com/angular/angular/issues/18261 + */ +export class CustomHttpParameterCodec implements HttpParameterCodec { + encodeKey(k: string): string { + return encodeURIComponent(k); + } + encodeValue(v: string): string { + return encodeURIComponent(v); + } + decodeKey(k: string): string { + return decodeURIComponent(k); + } + decodeValue(v: string): string { + return decodeURIComponent(v); + } +} + +export class IdentityHttpParameterCodec implements HttpParameterCodec { + encodeKey(k: string): string { + return k; + } + encodeValue(v: string): string { + return v; + } + decodeKey(k: string): string { + return k; + } + decodeValue(v: string): string { + return v; + } +} diff --git a/src/app/api/v1/git_push.sh b/src/app/api/v1/git_push.sh new file mode 100644 index 0000000..f53a75d --- /dev/null +++ b/src/app/api/v1/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/src/app/api/v1/index.ts b/src/app/api/v1/index.ts new file mode 100644 index 0000000..02cb7d4 --- /dev/null +++ b/src/app/api/v1/index.ts @@ -0,0 +1,7 @@ +export * from './api/api'; +export * from './model/models'; +export * from './variables'; +export * from './configuration'; +export * from './api.module'; +export * from './provide-api'; +export * from './param'; diff --git a/src/app/api/v1/model/hTTPValidationError.ts b/src/app/api/v1/model/hTTPValidationError.ts new file mode 100644 index 0000000..88b3971 --- /dev/null +++ b/src/app/api/v1/model/hTTPValidationError.ts @@ -0,0 +1,16 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { ValidationError } from './validationError'; + + +export interface HTTPValidationError { + detail?: Array; +} + diff --git a/src/app/api/v1/model/locationInner.ts b/src/app/api/v1/model/locationInner.ts new file mode 100644 index 0000000..3f65782 --- /dev/null +++ b/src/app/api/v1/model/locationInner.ts @@ -0,0 +1,14 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface LocationInner { +} + diff --git a/src/app/api/v1/model/models.ts b/src/app/api/v1/model/models.ts new file mode 100644 index 0000000..b5e109c --- /dev/null +++ b/src/app/api/v1/model/models.ts @@ -0,0 +1,4 @@ +export * from './hTTPValidationError'; +export * from './locationInner'; +export * from './toolInputDocument'; +export * from './validationError'; diff --git a/src/app/api/v1/model/toolInputDocument.ts b/src/app/api/v1/model/toolInputDocument.ts new file mode 100644 index 0000000..8bed26a --- /dev/null +++ b/src/app/api/v1/model/toolInputDocument.ts @@ -0,0 +1,21 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * The tool\'s input document, exactly as published at /v1/tools/{tool}/input-schema. It is validated against that schema before the job starts, and a rejection names the offending field. No wrapper object: the body is the document itself. Most tools take an object; a few, such as somn, take an array. The example below is for novostoic-optstoic -- fetch the tool\'s own schema for anything else. + */ +/** + * @type ToolInputDocument + * The tool\'s input document, exactly as published at /v1/tools/{tool}/input-schema. It is validated against that schema before the job starts, and a rejection names the offending field. No wrapper object: the body is the document itself. Most tools take an object; a few, such as somn, take an array. The example below is for novostoic-optstoic -- fetch the tool\'s own schema for anything else. + * @export + */ +export type ToolInputDocument = Array | object; + diff --git a/src/app/api/v1/model/validationError.ts b/src/app/api/v1/model/validationError.ts new file mode 100644 index 0000000..302fc71 --- /dev/null +++ b/src/app/api/v1/model/validationError.ts @@ -0,0 +1,18 @@ +/** + * AlphaSynthesis Tools API + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { LocationInner } from './locationInner'; + + +export interface ValidationError { + loc: Array; + msg: string; + type: string; +} + diff --git a/src/app/api/v1/param.ts b/src/app/api/v1/param.ts new file mode 100644 index 0000000..78a2d20 --- /dev/null +++ b/src/app/api/v1/param.ts @@ -0,0 +1,69 @@ +/** + * Standard parameter styles defined by OpenAPI spec + */ +export type StandardParamStyle = + | 'matrix' + | 'label' + | 'form' + | 'simple' + | 'spaceDelimited' + | 'pipeDelimited' + | 'deepObject' + ; + +/** + * The OpenAPI standard {@link StandardParamStyle}s may be extended by custom styles by the user. + */ +export type ParamStyle = StandardParamStyle | string; + +/** + * Standard parameter locations defined by OpenAPI spec + */ +export type ParamLocation = 'query' | 'header' | 'path' | 'cookie'; + +/** + * Standard types as defined in OpenAPI Specification: Data Types + */ +export type StandardDataType = + | "integer" + | "number" + | "boolean" + | "string" + | "object" + | "array" + ; + +/** + * Standard {@link DataType}s plus your own types/classes. + */ +export type DataType = StandardDataType | string; + +/** + * Standard formats as defined in OpenAPI Specification: Data Types + */ +export type StandardDataFormat = + | "int32" + | "int64" + | "float" + | "double" + | "byte" + | "binary" + | "date" + | "date-time" + | "password" + ; + +export type DataFormat = StandardDataFormat | string; + +/** + * The parameter to encode. + */ +export interface Param { + name: string; + value: unknown; + in: ParamLocation; + style: ParamStyle, + explode: boolean; + dataType: DataType; + dataFormat: DataFormat | undefined; +} diff --git a/src/app/api/v1/provide-api.ts b/src/app/api/v1/provide-api.ts new file mode 100644 index 0000000..19c762a --- /dev/null +++ b/src/app/api/v1/provide-api.ts @@ -0,0 +1,15 @@ +import { EnvironmentProviders, makeEnvironmentProviders } from "@angular/core"; +import { Configuration, ConfigurationParameters } from './configuration'; +import { BASE_PATH } from './variables'; + +// Returns the service class providers, to be used in the [ApplicationConfig](https://angular.dev/api/core/ApplicationConfig). +export function provideApi(configOrBasePath: string | ConfigurationParameters): EnvironmentProviders { + return makeEnvironmentProviders([ + typeof configOrBasePath === "string" + ? { provide: BASE_PATH, useValue: configOrBasePath } + : { + provide: Configuration, + useValue: new Configuration({ ...configOrBasePath }), + }, + ]); +} \ No newline at end of file diff --git a/src/app/api/v1/query.params.ts b/src/app/api/v1/query.params.ts new file mode 100644 index 0000000..4076c11 --- /dev/null +++ b/src/app/api/v1/query.params.ts @@ -0,0 +1,160 @@ +import { HttpParams, HttpParameterCodec } from '@angular/common/http'; +import { CustomHttpParameterCodec, IdentityHttpParameterCodec } from './encoder'; + +export enum QueryParamStyle { + Json, + Form, + DeepObject, + SpaceDelimited, + PipeDelimited, +} + +export type Delimiter = "," | " " | "|" | "\t"; + +export interface ParamOptions { + /** When true, serialized as multiple repeated key=value pairs. When false, serialized as a single key with joined values using `delimiter`. */ + explode?: boolean; + /** Delimiter used when explode=false. The delimiter itself is inserted unencoded between encoded values. */ + delimiter?: Delimiter; +} + +interface ParamEntry { + values: string[]; + options: Required; +} + +export class OpenApiHttpParams { + private params: Map = new Map(); + private defaults: Required; + private encoder: HttpParameterCodec; + + /** + * @param encoder Parameter serializer + * @param defaults Global defaults used when a specific parameter has no explicit options. + * By OpenAPI default, explode is true for query params with style=form. + */ + constructor(encoder?: HttpParameterCodec, defaults?: { explode?: boolean; delimiter?: Delimiter }) { + this.encoder = encoder || new CustomHttpParameterCodec(); + this.defaults = { + explode: defaults?.explode ?? true, + delimiter: defaults?.delimiter ?? ",", + }; + } + + private resolveOptions(local?: ParamOptions): Required { + return { + explode: local?.explode ?? this.defaults.explode, + delimiter: local?.delimiter ?? this.defaults.delimiter, + }; + } + + /** + * Replace the parameter's values and (optionally) its options. + * Options are stored per-parameter (not global). + */ + set(key: string, values: string[] | string, options?: ParamOptions): this { + const arr = Array.isArray(values) ? values.slice() : [values]; + const opts = this.resolveOptions(options); + this.params.set(key, {values: arr, options: opts}); + return this; + } + + /** + * Append a single value to the parameter. If the parameter didn't exist it will be created + * and use resolved options (global defaults merged with any provided options). + */ + append(key: string, value: string, options?: ParamOptions): this { + const entry = this.params.get(key); + if (entry) { + // If new options provided, override the stored options for subsequent serialization + if (options) { + entry.options = this.resolveOptions({...entry.options, ...options}); + } + entry.values.push(value); + } else { + this.set(key, [value], options); + } + return this; + } + + /** + * Serialize to a query string according to per-parameter OpenAPI options. + * - If explode=true for that parameter → repeated key=value pairs (each value encoded). + * - If explode=false for that parameter → single key=value where values are individually encoded + * and joined using the configured delimiter. The delimiter character is inserted AS-IS + * (not percent-encoded). + */ + toString(): string { + const records = this.toRecord(); + const parts: string[] = []; + + for (const key in records) { + parts.push(`${key}=${records[key]}`); + } + + return parts.join("&"); + } + + /** + * Return parameters as a plain record. + * - If a parameter has exactly one value, returns that value directly. + * - If a parameter has multiple values, returns a readonly array of values. + */ + toRecord(): Record> { + const parts: Record> = {}; + + for (const [key, entry] of this.params.entries()) { + const encodedKey = this.encoder.encodeKey(key); + + if (entry.options.explode) { + parts[encodedKey] = entry.values.map((v) => this.encoder.encodeValue(v)); + } else { + const encodedValues = entry.values.map((v) => this.encoder.encodeValue(v)); + + // join with the delimiter *unencoded* + parts[encodedKey] = encodedValues.join(entry.options.delimiter); + } + } + + return parts; + } + + /** + * Return an Angular's HttpParams with an identity parameter codec as the parameters are already encoded. + */ + toHttpParams(): HttpParams { + const records = this.toRecord(); + + let httpParams = new HttpParams({encoder: new IdentityHttpParameterCodec()}); + + return httpParams.appendAll(records); + } +} + +export function concatHttpParamsObject(httpParams: OpenApiHttpParams, key: string, item: { + [index: string]: any +}, delimiter: Delimiter): OpenApiHttpParams { + let keyAndValues: string[] = []; + + for (const k in item) { + keyAndValues.push(k); + + const value = item[k]; + + if (Array.isArray(value)) { + keyAndValues.push(...value.map(convertToString)); + } else { + keyAndValues.push(convertToString(value)); + } + } + + return httpParams.set(key, keyAndValues, {explode: false, delimiter: delimiter}); +} + +function convertToString(value: any): string { + if (value instanceof Date) { + return value.toISOString(); + } else { + return value.toString(); + } +} diff --git a/src/app/api/v1/variables.ts b/src/app/api/v1/variables.ts new file mode 100644 index 0000000..6fe5854 --- /dev/null +++ b/src/app/api/v1/variables.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@angular/core'; + +export const BASE_PATH = new InjectionToken('basePath'); +export const COLLECTION_FORMATS = { + 'csv': ',', + 'tsv': ' ', + 'ssv': ' ', + 'pipes': '|' +} diff --git a/src/stories/fixtures/job-result--default.json b/src/stories/fixtures/job-result--default.json deleted file mode 100644 index e0326b8..0000000 --- a/src/stories/fixtures/job-result--default.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "result": "ok" -} \ No newline at end of file diff --git a/src/stories/fixtures/job-result--null.json b/src/stories/fixtures/job-result--null.json deleted file mode 100644 index ec747fa..0000000 --- a/src/stories/fixtures/job-result--null.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/src/stories/fixtures/job-status--completed.json b/src/stories/fixtures/job-status--completed.json deleted file mode 100644 index 338aefd..0000000 --- a/src/stories/fixtures/job-status--completed.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "email": "", - "job_id": "example-job", - "phase": "completed", - "image": "test/image", - "time_created": 1728313369, - "time_end": 0, - "user_agent": "", - "job_info": "{\"smiles\":\"O=C(COP(=O)(O)O)[C@H](O)[C@H](O)CO\"}", - "run_id": "example-run", - "type": "test", - "command": "command", - "time_start": 0, - "deleted": 0 -} \ No newline at end of file diff --git a/src/stories/fixtures/job-status--error.json b/src/stories/fixtures/job-status--error.json deleted file mode 100644 index 0122b9f..0000000 --- a/src/stories/fixtures/job-status--error.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "email": "", - "job_id": "example-job", - "phase": "error", - "image": "test/image", - "time_created": 1728313369, - "time_end": 0, - "user_agent": "", - "job_info": "{\"smiles\":\"O=C(COP(=O)(O)O)[C@H](O)[C@H](O)CO\"}", - "run_id": "example-run", - "type": "test", - "command": "command", - "time_start": 0, - "deleted": 0 -} \ No newline at end of file diff --git a/src/stories/fixtures/job-status--processing.json b/src/stories/fixtures/job-status--processing.json deleted file mode 100644 index 0a66ca0..0000000 --- a/src/stories/fixtures/job-status--processing.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "email": "", - "job_id": "example-job", - "phase": "processing", - "image": "test/image", - "time_created": 1728313369, - "time_end": 0, - "user_agent": "", - "job_info": "{\"smiles\":\"O=C(COP(=O)(O)O)[C@H](O)[C@H](O)CO\"}", - "run_id": "example-run", - "type": "test", - "command": "command", - "time_start": 0, - "deleted": 0 -} \ No newline at end of file diff --git a/src/stories/fixtures/job-status--queued.json b/src/stories/fixtures/job-status--queued.json deleted file mode 100644 index b87d842..0000000 --- a/src/stories/fixtures/job-status--queued.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "email": "", - "job_id": "example-job", - "phase": "queued", - "image": "test/image", - "time_created": 1728313369, - "time_end": 0, - "user_agent": "", - "job_info": "{\"smiles\":\"O=C(COP(=O)(O)O)[C@H](O)[C@H](O)CO\"}", - "run_id": "example-run", - "type": "test", - "command": "command", - "time_start": 0, - "deleted": 0 -} \ No newline at end of file From 2e1c81cb0e52915aec58f33849f08b6ada5ff408 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 20 Oct 2026 10:24:55 -0500 Subject: [PATCH 02/12] feat: submit, poll and show results through /v1, at /result/:id - The form posts the input document and any files as multipart, with the notification address in X-Notify-Email, and shows the problem document when the backend refuses the input. - Loading tells queued (waiting for a node) from processing, and shows error and canceled states. - The results page moves from test/result/:id to result/:id, the route the backend's default notification email links to. - app.yaml's job_type reaches the app as APP_IDENTITY.jobType. The template's is job-template-sample, which the backend has not registered yet. - e2e: submit -> queued -> processing -> results against a mock /v1 API. nightwatch/tsconfig targets es2017: compiled to es5, async tests are not detected as async and their awaited commands do not wait. Part of #17 --- README.md | 26 +++- app.yaml | 6 +- nightwatch/src/job_flow_spec.ts | 122 ++++++++++++++++++ nightwatch/tsconfig.json | 2 +- scripts/app-sync.mjs | 1 + scripts/app-sync.test.mjs | 5 + src/app/app-identity.ts | 1 + src/app/app-routing.module.ts | 3 +- .../components/loading/loading.component.html | 27 +++- .../loading/loading.component.spec.ts | 46 ++++++- .../components/loading/loading.component.ts | 24 ++-- .../test-result/test-result.component.html | 27 +++- .../test-result/test-result.component.spec.ts | 2 +- .../test-result/test-result.component.ts | 15 ++- .../test-result/test-result.polling.spec.ts | 9 +- src/app/components/test/test.component.html | 24 +++- .../components/test/test.component.spec.ts | 97 ++++++++++++-- src/app/components/test/test.component.ts | 88 +++++++++---- src/app/models/job-result.spec.ts | 47 ++++--- src/app/models/job-result.ts | 21 +-- src/app/models/v1-job.ts | 40 ++++++ .../param-change-route-reuse.strategy.spec.ts | 6 +- src/app/param-change-route-reuse.strategy.ts | 2 +- src/app/services/environment.service.ts | 8 +- src/app/services/test.examples.ts | 31 +++++ src/app/services/test.service.spec.ts | 83 ++++++++++++ src/app/services/test.service.ts | 78 ++++++----- src/main.ts | 3 + src/stories/storybook.config.ts | 106 +++------------ 29 files changed, 707 insertions(+), 243 deletions(-) create mode 100644 nightwatch/src/job_flow_spec.ts create mode 100644 src/app/models/v1-job.ts create mode 100644 src/app/services/test.examples.ts create mode 100644 src/app/services/test.service.spec.ts diff --git a/README.md b/README.md index 1fb26eb..09b691c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,26 @@ driven by `environment` and `prodUrl` in the chart's runtime config. The family selects the header branding (`src/app/family-branding.ts`) and the deployment conventions (prod domain, namespace). Everything else is shared. +## The job flow (`/v1`) + +The app submits to mmli-backend's versioned API: the form (`/test`) posts the tool's +input document and any files as multipart, Loading shows the job queued and then +processing, and the results page is `/result/:id`, the route the backend's +notification email links to. + +- **API client:** `src/app/api/v1`, generated from the snapshot in + `openapi/mmli-backend-v1.openapi.json`. `npm run openapi:v1:sync` refreshes the + snapshot from staging and regenerates; `npm run openapi:v1` only regenerates. It uses + openapi-generator 7.24.0 (`openapitools.v1.json`), because 6.6.0, which the legacy + client in `src/app/api/mmli-backend` uses, reads FastAPI's OpenAPI 3.1 parameters as + `any`. +- **Job input and result types:** `src/app/api/tool-schemas.ts` (`ToolInput`, + `ToolResults`), generated from `schemas/input.schema.json` and + `schemas/output.schema.json`. `npm run schemas:sync` fetches both for `app.yaml`'s + `job_type` from the staging backend and regenerates; `npm run schemas` only + regenerates. CI runs `npm run schemas -- --check`. The template's schemas are the + sample tool's, from [job-template](https://github.com/moleculemaker/job-template). + ## Components All builtin components are inside the `src/app/components` folder. @@ -63,7 +83,11 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. ## Running end-to-end tests -Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. +Start the app (`npm start`), then run `npx nightwatch ./nightwatch --env chrome`. The +job-flow test runs against a mock of the `/v1` API that it starts itself, and points the +app at it by answering the `envvars.json` request over the Chrome DevTools Protocol, so +it needs Chrome and a chromedriver that matches it (`CHROMEWEBDRIVER` is the directory +containing one). ## Further help diff --git a/app.yaml b/app.yaml index b6ba010..db1a817 100644 --- a/app.yaml +++ b/app.yaml @@ -19,8 +19,10 @@ family: alphasynthesis # own; -1 until it is created. matomo_site_id: -1 -# The mmli-backend job type this app submits, once registered there. -job_type: null +# The mmli-backend job type this app submits, once registered there. Its input and +# output schemas generate the app's job types (`npm run schemas:sync`). The template's +# is the job-template sample tool, which the backend has not registered yet. +job_type: job-template-sample # The compute image the backend runs for this app (ghcr.io//-job:). compute_image: null diff --git a/nightwatch/src/job_flow_spec.ts b/nightwatch/src/job_flow_spec.ts new file mode 100644 index 0000000..0d247b5 --- /dev/null +++ b/nightwatch/src/job_flow_spec.ts @@ -0,0 +1,122 @@ +import { createServer, IncomingMessage, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { NightwatchTests } from 'nightwatch'; + +/** + * Submit -> queued -> processing -> results, against a mock of mmli-backend's /v1 API. + * + * The app reads the API's address from /assets/config/envvars.json at startup; the + * test answers that request (over CDP, so Chrome only) with one pointing at the mock. + * The mock reports the job queued, then processing, then completed, one status per + * poll. The app polls every 10s, so this test takes about half a minute. + */ +const TESTING_URL = process.env['TESTING_URL'] || 'http://localhost:4200'; +const JOB_ID = 'e2e-job'; +const STATUSES = ['queued', 'processing', 'completed']; +const RESULTS = { + rows: [{ id: 'insulin-b', length: 30, molecular_weight_da: 3429.96 }], + artifacts: [{ name: 'results.csv', media_type: 'text/csv', description: 'All rows as CSV' }], +}; + +let server: Server; +let api = ''; +let polls = 0; +let submitted: { tool: string; body: string; contentType: string; email?: string } | undefined; + +const job = (status: string) => ({ + job_id: JOB_ID, tool: 'job-template-sample', status, parent_job_id: null, + submitted_at: new Date().toISOString(), started_at: null, finished_at: null, + inputs: { sequences: [{ id: 'insulin-b', sequence: 'FVNQHLCGSHLVEALYLVCGERGFFYTPKT' }] }, +}); + +function read(request: IncomingMessage): Promise { + return new Promise((resolve) => { + let body = ''; + request.on('data', (chunk) => (body += chunk)); + request.on('end', () => resolve(body)); + }); +} + +/** Waits in the test process, where the mock API records what it received. */ +async function until(condition: () => boolean, timeoutMs: number): Promise { + for (let waited = 0; !condition() && waited < timeoutMs; waited += 100) { + await browser.pause(100); + } +} + +function startMockApi(): Promise { + server = createServer(async (request, response) => { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Notify-Email'); + if (request.method === 'OPTIONS') return response.writeHead(204).end(); + + const send = (status: number, body: unknown) => + response.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body)); + const url = request.url ?? ''; + const submit = url.match(/^\/v1\/tools\/([^/]+)\/jobs$/); + + if (request.method === 'POST' && submit) { + submitted = { + tool: submit[1], + body: await read(request), + contentType: request.headers['content-type'] ?? '', + email: request.headers['x-notify-email'] as string | undefined, + }; + return send(201, job('queued')); + } + if (url === `/v1/jobs/${JOB_ID}`) return send(200, job(STATUSES[Math.min(polls++, STATUSES.length - 1)])); + if (url === `/v1/jobs/${JOB_ID}/results`) return send(200, { job_id: JOB_ID, tool: 'job-template-sample', results: RESULTS }); + send(404, { type: 'about:blank', title: 'Not found', status: 404 }); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => { + api = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + resolve(); + })); +} + +const jobFlow: NightwatchTests = { + before: async () => { + await startMockApi(); + await browser.network.mockResponse(`${TESTING_URL}/assets/config/envvars.json`, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hostname: api, basePath: '', signInUrl: '', signOutUrl: '', userInfoUrl: `${api}/userinfo`, + frontendOnly: 'false', environment: 'prod', prodUrl: '', + }), + }); + }, + + after: () => { + server?.close(); + }, + + 'submits the form as multipart with the email in a header': async () => { + await browser.url(`${TESTING_URL}/test`); + await browser.waitForElementVisible('[data-testid="sequences"]'); + await browser.setValue('[data-testid="sequences"]', '>insulin-b\nFVNQHLCGSHLVEALYLVCGERGFFYTPKT'); + await browser.setValue('#subscriber-email', 'e2e@example.org'); + await browser.click('[data-testid="submit"] button'); + + await until(() => submitted !== undefined, 5000); + browser.assert.ok(submitted !== undefined, 'the job was submitted'); + browser.assert.equal(submitted!.tool, 'job-template-sample'); + browser.assert.ok(submitted!.contentType.startsWith('multipart/form-data'), 'sent as multipart'); + browser.assert.ok(submitted!.body.includes('"sequence":"FVNQHLCGSHLVEALYLVCGERGFFYTPKT"'), 'inputs part holds the document'); + browser.assert.equal(submitted!.email, 'e2e@example.org'); + }, + + 'lands on /result/:id and shows queued, then processing': async () => { + await browser.assert.urlContains(`/result/${JOB_ID}`); + await browser.waitForElementVisible('[data-testid="job-queued"]', 5000); + await browser.waitForElementVisible('[data-testid="job-processing"]', 15000); + }, + + 'shows the results once the job completes': async () => { + await browser.waitForElementVisible('[data-testid="results-table"]', 15000); + browser.assert.textContains('[data-testid="results-table"]', 'insulin-b'); + browser.assert.textContains('[data-testid="results-table"]', '3429.96'); + }, +}; + +export default jobFlow; diff --git a/nightwatch/tsconfig.json b/nightwatch/tsconfig.json index f7e6ab1..a5e191a 100644 --- a/nightwatch/tsconfig.json +++ b/nightwatch/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "../out-tsc/nightwatch", "module": "commonjs", "moduleResolution": "node", - "target": "es5", + "target": "es2017", "types": [ "node", "nightwatch" diff --git a/scripts/app-sync.mjs b/scripts/app-sync.mjs index 7f28bbb..ec1bc6c 100644 --- a/scripts/app-sync.mjs +++ b/scripts/app-sync.mjs @@ -135,6 +135,7 @@ export function render(app) { ` family: ${JSON.stringify(app.family)},`, ` matomoSiteId: ${app.matomo_site_id},`, ` feedbackEmail: ${JSON.stringify(d.feedback_email)},`, + ` jobType: ${JSON.stringify(app.job_type ?? null)},`, '} as const;', '', ].join('\n'), diff --git a/scripts/app-sync.test.mjs b/scripts/app-sync.test.mjs index fd1494f..046ad44 100644 --- a/scripts/app-sync.test.mjs +++ b/scripts/app-sync.test.mjs @@ -53,6 +53,11 @@ test('the identity module carries the family and the derived feedback address', assert.match(ts, /feedbackEmail: "mutagenesis-feedback@ibiofoundry\.illinois\.edu"/); }); +test('the identity module names the job type the app submits, or null', () => { + assert.match(render({ ...MUTAGENESIS, job_type: 'mutagenesis' })['src/app/app-identity.ts'], /jobType: "mutagenesis"/); + assert.match(render(MUTAGENESIS)['src/app/app-identity.ts'], /jobType: null/); +}); + test('managed lines take the derived value and keep their marker', () => { const text = [ 'env:', diff --git a/src/app/app-identity.ts b/src/app/app-identity.ts index f494d3e..2fcb9ce 100644 --- a/src/app/app-identity.ts +++ b/src/app/app-identity.ts @@ -5,4 +5,5 @@ export const APP_IDENTITY = { family: "alphasynthesis", matomoSiteId: -1, feedbackEmail: "test-feedback@moleculemaker.org", + jobType: "job-template-sample", } as const; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index a276118..0e6962a 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,7 +10,8 @@ import { ParamChangeRouteReuseStrategy } from "./param-change-route-reuse.strate const routes: Routes = [ { path: "", pathMatch: "full", component: MainLayoutComponent }, { path: 'test', component: TestComponent }, - { path: 'test/result/:id', component: TestResultComponent }, + // The backend's notification email links here: {frontend_url}/result/{job_id} + { path: 'result/:id', component: TestResultComponent }, ]; @NgModule({ diff --git a/src/app/components/loading/loading.component.html b/src/app/components/loading/loading.component.html index f705503..57dbac7 100644 --- a/src/app/components/loading/loading.component.html +++ b/src/app/components/loading/loading.component.html @@ -1,17 +1,24 @@ -
+@let status = (status$ | async)?.status; +
-
- Estimated processing time: {{ estimatedTimeString }} + @if (status === 'queued') { +
+ Queued: your job will start when a compute node is free.
+ } @else { +
+ Processing. Estimated processing time: {{ estimatedTimeString }} +
+ }
Error

An error occurs running the job.

-
\ No newline at end of file +
+ +
+
Canceled
+

This job was canceled before it finished.

+
diff --git a/src/app/components/loading/loading.component.spec.ts b/src/app/components/loading/loading.component.spec.ts index 36286d0..200fca6 100644 --- a/src/app/components/loading/loading.component.spec.ts +++ b/src/app/components/loading/loading.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; @@ -6,7 +6,7 @@ import { provideEnvironmentServiceStub } from '~/app/testing/environment-service import { of } from 'rxjs'; -import { Job, JobStatus } from '~/app/api/mmli-backend/v1'; +import { Job, JobStatus } from '~/app/models/v1-job'; import { LoadingComponent } from './loading.component'; describe('LoadingComponent', () => { @@ -22,11 +22,49 @@ describe('LoadingComponent', () => { fixture = TestBed.createComponent(LoadingComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('status$', of({ phase: JobStatus.Processing } as Job)); - fixture.detectChanges(); }); + function show(status: JobStatus) { + fixture.componentRef.setInput('status$', of({ status } as Job)); + fixture.detectChanges(); + } + + const shown = (testId: string) => fixture.nativeElement.querySelector(`[data-testid="${testId}"]`); + it('should create', () => { + show('processing'); expect(component).toBeTruthy(); }); + + it('says a queued job is waiting, not running', () => { + show('queued'); + expect(shown('job-queued')).not.toBeNull(); + expect(shown('job-processing')).toBeNull(); + }); + + it('shows the estimate once the job is processing', () => { + show('processing'); + expect(shown('job-processing')).not.toBeNull(); + expect(shown('job-queued')).toBeNull(); + }); + + // The terminal states are set by the progress stream, which ticks once a second + function showAfterATick(status: JobStatus) { + show(status); + tick(1000); + fixture.detectChanges(); + } + + it('shows the error state for a failed job', fakeAsync(() => { + showAfterATick('error'); + expect(shown('job-error')).not.toBeNull(); + discardPeriodicTasks(); + })); + + it('shows the canceled state for a canceled job, not the error', fakeAsync(() => { + showAfterATick('canceled'); + expect(shown('job-canceled')).not.toBeNull(); + expect(shown('job-error')).toBeNull(); + discardPeriodicTasks(); + })); }); diff --git a/src/app/components/loading/loading.component.ts b/src/app/components/loading/loading.component.ts index d677156..74d7e21 100644 --- a/src/app/components/loading/loading.component.ts +++ b/src/app/components/loading/loading.component.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, combineLatest, interval, map, Observable } from "rxjs" import { NgIf, AsyncPipe } from "@angular/common"; import { ProgressBarModule } from "primeng/progressbar"; -import { Job, JobStatus } from "~/app/api/mmli-backend/v1"; +import { Job } from "~/app/models/v1-job"; @Component({ selector: "app-loading", @@ -27,6 +27,7 @@ export class LoadingComponent implements OnInit { estimatedTime: number; estimatedTimeString: string; showError$ = new BehaviorSubject(false); + showCanceled$ = new BehaviorSubject(false); form = new FormGroup({ agreeToSubscription: new FormControl(false), @@ -41,12 +42,15 @@ export class LoadingComponent implements OnInit { interval(1000), ]).pipe( map(([status, _]) => { - //TODO: queued job should return 0, wait for backend to fix the status error - switch (status.phase) { + switch (status.status) { - case JobStatus.Queued: - case JobStatus.Processing: - const jobStartedMs = (status.time_created || 0) * 1000; // use created time for estimation for now + // Waiting for a node (often the one GPU node) or its image; nothing has run yet + case 'queued': + return 0; + + case 'processing': + // The backend does not record started_at yet, so fall back to submitted_at + const jobStartedMs = Date.parse(status.started_at ?? status.submitted_at ?? '') || Date.now(); const jobElapsedMs = Date.now() - jobStartedMs; const coe = 2 / 5; const estimatedMs = this.estimatedTime * 1000; @@ -62,12 +66,16 @@ export class LoadingComponent implements OnInit { sum += Math.pow(coe, i) * decimalTime; return sum * 100; - case JobStatus.Completed: + case 'completed': return 100; - case JobStatus.Error: + case 'error': this.showError$.next(true); return 0; + + case 'canceled': + this.showCanceled$.next(true); + return 0; } return 0; } diff --git a/src/app/components/test-result/test-result.component.html b/src/app/components/test-result/test-result.component.html index 08c36a9..42bf776 100644 --- a/src/app/components/test-result/test-result.component.html +++ b/src/app/components/test-result/test-result.component.html @@ -1,7 +1,7 @@ @if (currentPage === 'input') { - + } @else { @@ -18,7 +18,7 @@
Submission Time: - {{ (status.time_created || 0) * 1000 | date : "short" }} + {{ status.submitted_at | date : "short" }}
} @@ -36,11 +36,24 @@
@let response = response$ | async; @if (response) { - - - - - + + + + + + + @for (row of response.rows; track $index) { + + } + +
IDLengthMolecular weight (Da)
{{ row.id }}{{ row.length }}{{ row.molecular_weight_da }}
+ @if (response.artifacts.length) { + + } }
diff --git a/src/app/components/test-result/test-result.component.spec.ts b/src/app/components/test-result/test-result.component.spec.ts index 6e320e9..d4bc489 100644 --- a/src/app/components/test-result/test-result.component.spec.ts +++ b/src/app/components/test-result/test-result.component.spec.ts @@ -38,7 +38,7 @@ describe('TestResultComponent', () => { it('hands the form the job id it is showing, so it can recognise an unchanged resubmit', async () => { await firstValueFrom(component.statusResponse$); - expect(component.jobInfo.job_id).toBe(component.jobId); + expect(component.loadedJob?.job_id).toBe(component.jobId); }); // No route param in this fixture, so jobId falls back to the example id. diff --git a/src/app/components/test-result/test-result.component.ts b/src/app/components/test-result/test-result.component.ts index 6de9354..df16842 100644 --- a/src/app/components/test-result/test-result.component.ts +++ b/src/app/components/test-result/test-result.component.ts @@ -3,13 +3,13 @@ import { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { tap } from 'rxjs'; -import { JobType } from '~/app/api/mmli-backend/v1'; import { JobResult } from '~/app/models/job-result'; import { LoadingComponent } from '~/app/components/loading/loading.component'; import { JobTabComponent } from "~/app/components/job-tab/job-tab.component"; import { TestService } from '~/app/services/test.service'; -import { TestComponent } from '~/app/components/test/test.component'; +import { LoadedJob, TestComponent } from '~/app/components/test/test.component'; +import { ToolInput } from '@api/tool-schemas'; @Component({ selector: 'app-test-result', @@ -29,13 +29,12 @@ import { TestComponent } from '~/app/components/test/test.component'; }) export class TestResultComponent extends JobResult { override jobId: string = this.route.snapshot.paramMap.get("id") || "example-id"; - override jobType: JobType; //TODO: use the correct job type - + response$ = this.jobResultResponse$; currentPage = 'result'; - jobInfo: any = {}; + loadedJob?: LoadedJob; constructor( service: TestService, @@ -45,11 +44,15 @@ export class TestResultComponent extends JobResult { // The embedded form recognises a resubmit of this very job by the job_id. this.statusResponse$ = this.statusResponse$.pipe( tap((job) => { - this.jobInfo = { ...JSON.parse(job.job_info || '{}'), email: job.email || '', job_id: this.jobId }; + this.loadedJob = { job_id: this.jobId, inputs: job.inputs as ToolInput }; }), ); } + artifactUrl(name: string): string { + return this.service.artifactUrl(this.jobId, name); + } + /** * A resubmit that resolves to another job navigates there, and the route reuse * strategy rebuilds this page for it. One that resolves to this same job is a diff --git a/src/app/components/test-result/test-result.polling.spec.ts b/src/app/components/test-result/test-result.polling.spec.ts index db773d2..abf2b0b 100644 --- a/src/app/components/test-result/test-result.polling.spec.ts +++ b/src/app/components/test-result/test-result.polling.spec.ts @@ -4,7 +4,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; import { of } from 'rxjs'; -import { JobStatus } from '~/app/api/mmli-backend/v1'; import { TestService } from '~/app/services/test.service'; import { provideEnvironmentServiceStub } from '~/app/testing/environment-service.stub'; import { TestResultComponent } from './test-result.component'; @@ -15,8 +14,8 @@ import { TestResultComponent } from './test-result.component'; */ describe('TestResultComponent status polling', () => { it('makes one status request per poll while the loading screen is shown', fakeAsync(() => { - const service = jasmine.createSpyObj('TestService', ['getResultStatus', 'getResult']); - service.getResultStatus.and.callFake(() => of({ phase: JobStatus.Processing } as any)); + const service = jasmine.createSpyObj('TestService', ['getJob', 'getResults']); + service.getJob.and.callFake(() => of({ status: 'processing' } as any)); TestBed.configureTestingModule({ imports: [TestResultComponent], @@ -30,10 +29,10 @@ describe('TestResultComponent status polling', () => { fixture.detectChanges(); expect(fixture.nativeElement.querySelector('app-loading')).not.toBeNull(); - expect(service.getResultStatus).toHaveBeenCalledTimes(1); + expect(service.getJob).toHaveBeenCalledTimes(1); tick(10000); - expect(service.getResultStatus).toHaveBeenCalledTimes(2); + expect(service.getJob).toHaveBeenCalledTimes(2); fixture.destroy(); discardPeriodicTasks(); diff --git a/src/app/components/test/test.component.html b/src/app/components/test/test.component.html index dd4ab0f..ac92083 100644 --- a/src/app/components/test/test.component.html +++ b/src/app/components/test/test.component.html @@ -16,7 +16,27 @@
- + +
+ + + + + +
+ + @if (problem) { + + } @@ -47,7 +67,7 @@
Leave an email to get an notification when y
- @if (requestUnchanged) { diff --git a/src/app/components/test/test.component.spec.ts b/src/app/components/test/test.component.spec.ts index ba578c3..df1b19b 100644 --- a/src/app/components/test/test.component.spec.ts +++ b/src/app/components/test/test.component.spec.ts @@ -1,20 +1,22 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { provideHttpClient } from '@angular/common/http'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter, Router } from '@angular/router'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; -import { TestComponent } from './test.component'; +import { LoadedJob, TestComponent, fromFasta, toFasta } from './test.component'; import { TestService } from '~/app/services/test.service'; -import { Job } from '~/app/api/mmli-backend/v1'; +import { Job } from '~/app/models/v1-job'; import { provideEnvironmentServiceStub } from '~/app/testing/environment-service.stub'; +const FASTA = '>insulin-b\nFVNQHLCGSHLVEALYLVCGERGFFYTPKT\n>glucagon\nHSQGTFTSDYSKY\nLDSRRAQDFVQWLMNT'; + describe('TestComponent', () => { let component: TestComponent; let fixture: ComponentFixture; - let createAndRunJob: jasmine.Spy; + let submit: jasmine.Spy; let navigate: jasmine.Spy; let emitted: string[]; @@ -29,41 +31,95 @@ describe('TestComponent', () => { component = fixture.componentInstance; fixture.detectChanges(); - createAndRunJob = spyOn(TestBed.inject(TestService), 'createAndRunJob').and.returnValue(of({ job_id: 'job-b' } as Job)); + submit = spyOn(TestBed.inject(TestService), 'submit').and.returnValue(of({ job_id: 'job-b' } as Job)); navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); emitted = []; component.submitted.subscribe((jobId) => emitted.push(jobId)); + component.form.patchValue({ sequences: FASTA }); }); it('should create', () => { expect(component).toBeTruthy(); }); + it('submits the input document, the chosen files and the email', () => { + const file = new File(['x'], 'extra.txt'); + component.files = [file]; + component.form.patchValue({ email: 'a@b.c' }); + + component.onSubmit(); + + expect(submit).toHaveBeenCalledWith({ sequences: fromFasta(FASTA) }, [file], 'a@b.c'); + }); + it('emits the created job id once the submission resolves, then navigates to it', () => { component.onSubmit(); - expect(createAndRunJob).toHaveBeenCalled(); expect(emitted).toEqual(['job-b']); - expect(navigate).toHaveBeenCalledWith(['test', 'result', 'job-b']); + expect(navigate).toHaveBeenCalledWith(['result', 'job-b']); + }); + + it('shows the problem the backend reports for invalid input, and stays on the form', () => { + submit.and.returnValue(throwError(() => new HttpErrorResponse({ + status: 422, + error: { + type: 'https://mmli.fastapi.mmli2.ncsa.illinois.edu/v1/problems/invalid-input', + title: 'Input does not match the tool schema', + status: 422, + errors: [{ pointer: '/sequences/0/sequence', detail: "'B' does not match" }], + }, + }))); + + component.onSubmit(); + fixture.detectChanges(); + + const shown: HTMLElement = fixture.nativeElement.querySelector('[data-testid="submit-problem"]'); + expect(shown.textContent).toContain('Input does not match the tool schema'); + expect(shown.textContent).toContain('/sequences/0/sequence'); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('reports a failure with no problem document, such as a network error', () => { + submit.and.returnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + + component.onSubmit(); + + expect(component.problem?.title).toBe('The job could not be submitted'); }); + + describe('FASTA', () => { + it('reads one sequence per record, joining wrapped lines', () => { + expect(fromFasta(FASTA)).toEqual([ + { id: 'insulin-b', sequence: 'FVNQHLCGSHLVEALYLVCGERGFFYTPKT' }, + { id: 'glucagon', sequence: 'HSQGTFTSDYSKYLDSRRAQDFVQWLMNT' }, + ]); + }); + + it('round-trips through the input document', () => { + expect(fromFasta(toFasta({ sequences: fromFasta(FASTA) }))).toEqual(fromFasta(FASTA)); + }); + }); + describe('resubmitting a loaded job', () => { - // The template's request payload is empty, so any loaded job reads as unchanged - // until an app fills in buildJobInfo(); the compare itself is what is under test. - const loadedJob = { job_id: 'job-a', email: 'a@b.c' }; + const loadedJob: LoadedJob = { job_id: 'job-a', inputs: { sequences: fromFasta(FASTA) } }; beforeEach(() => { component.formValue = loadedJob; component.ngOnChanges({ formValue: new SimpleChange(null, loadedJob, true) }); }); + it('fills the form from the job it shows', () => { + expect(fromFasta(component.form.controls.sequences.value!)).toEqual(loadedJob.inputs.sequences); + }); + it('returns to the loaded job instead of creating a duplicate when the request is unchanged', () => { expect(component.requestUnchanged).toBeTrue(); component.onSubmit(); - expect(createAndRunJob).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); expect(emitted).toEqual(['job-a']); - expect(navigate).toHaveBeenCalledWith(['test', 'result', 'job-a']); + expect(navigate).toHaveBeenCalledWith(['result', 'job-a']); }); // Email is a notification setting, not part of the request. @@ -73,8 +129,21 @@ describe('TestComponent', () => { expect(component.requestUnchanged).toBeTrue(); }); + it('treats an edited input as a new request', () => { + component.form.patchValue({ sequences: '>one\nACD' }); + + expect(component.requestUnchanged).toBeFalse(); + }); + + // What the job received can't be compared with a newly chosen file + it('treats chosen files as a new request', () => { + component.files = [new File(['x'], 'extra.txt')]; + + expect(component.requestUnchanged).toBeFalse(); + }); + it('is never unchanged on the standalone page, which has no loaded job', () => { - component.formValue = null; + component.formValue = undefined; expect(component.requestUnchanged).toBeFalse(); }); diff --git a/src/app/components/test/test.component.ts b/src/app/components/test/test.component.ts index 27283ad..5139ed3 100644 --- a/src/app/components/test/test.component.ts +++ b/src/app/components/test/test.component.ts @@ -1,14 +1,22 @@ import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from "@angular/forms"; +import { HttpErrorResponse } from "@angular/common/http"; import { Router } from "@angular/router"; import { CheckboxModule } from "primeng/checkbox"; import { ButtonModule } from "primeng/button"; import { CommonModule } from "@angular/common"; -import { JobType } from "~/app/api/mmli-backend/v1"; +import { ToolInput } from "@api/tool-schemas"; +import { Problem } from "~/app/models/v1-job"; import { JobTabComponent } from "~/app/components/job-tab/job-tab.component"; import { TestService } from '~/app/services/test.service'; +/** What a result page hands this form: the job it shows, to edit and resubmit. */ +export interface LoadedJob { + job_id: string; + inputs: ToolInput; +} + @Component({ selector: 'app-test', templateUrl: './test.component.html', @@ -27,7 +35,7 @@ import { TestService } from '~/app/services/test.service'; } }) export class TestComponent implements OnChanges { - @Input() formValue!: any; // TODO: update type + @Input() formValue?: LoadedJob; @Input() showJobTab = true; /** * The job id a submission resolved to, emitted just before navigating to it. The @@ -38,10 +46,18 @@ export class TestComponent implements OnChanges { @Output() submitted = new EventEmitter(); form = new FormGroup({ + // TODO: replace with your tool's inputs. The sample tool takes protein sequences. + sequences: new FormControl("", [Validators.required]), email: new FormControl("", [Validators.email]), agreeToSubscription: new FormControl(false), }); + /** Files sent with the job, which the tool reads from its input directory. */ + files: File[] = []; + + /** The backend's reason for refusing the last submission, if it did. */ + problem: Problem | null = null; + currentPage = 'input'; constructor( @@ -51,28 +67,31 @@ export class TestComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if (changes['formValue'] && changes['formValue'].currentValue) { - this.form.patchValue(this.formValue); + this.form.patchValue({ sequences: toFasta(this.formValue!.inputs) }); } } - /** The request payload as the job will store it in job_info. */ - buildJobInfo(): Record { - return { - // TODO: add job info here - }; + onFilesSelected(event: Event): void { + this.files = Array.from((event.target as HTMLInputElement).files ?? []); + } + + /** The input document, exactly as the tool's input schema describes it. */ + buildInputs(): ToolInput { + // TODO: build your tool's input document from the form + return { sequences: fromFasta(this.form.controls.sequences.value ?? '') }; } /** * True when the form would resubmit exactly the request the loaded job ran. * Email is a notification setting, not part of the request, so changing only it - * does not make a new job. Only a result page hands this form a job_id, so on - * the standalone page this is always false. + * does not make a new job. Files can't be compared with what the job received, so + * choosing any makes it a new request. Only a result page hands this form a job, + * so on the standalone page this is always false. */ get requestUnchanged(): boolean { const job = this.formValue; - if (!job?.job_id) return false; - const { job_id, email, ...loadedJobInfo } = job; - return stableStringify(this.buildJobInfo()) === stableStringify(loadedJobInfo); + if (!job?.job_id || this.files.length) return false; + return stableStringify(this.buildInputs()) === stableStringify(job.inputs); } onSubmit() { @@ -83,24 +102,43 @@ export class TestComponent implements OnChanges { // Resubmitting the loaded job's own request would only queue a duplicate of a // result that already exists, so return to that result instead. if (this.requestUnchanged) { - this.submitted.emit(this.formValue.job_id); - this.router.navigate(['test', 'result', this.formValue.job_id]); + this.submitted.emit(this.formValue!.job_id); + this.router.navigate(['result', this.formValue!.job_id]); return; } - this.service.createAndRunJob( - JobType.Somn, //TODO: use the correct job type - { - job_info: JSON.stringify(this.buildJobInfo()), - email: this.form.controls["email"].value || '', - } - ).subscribe((response) => { - this.submitted.emit(response.job_id); - this.router.navigate(['test', 'result', response.job_id]); - }) + this.problem = null; + this.service.submit( + this.buildInputs(), + this.files, + this.form.controls.email.value || '', + ).subscribe({ + next: (job) => { + this.submitted.emit(job.job_id); + this.router.navigate(['result', job.job_id]); + }, + // /v1 errors are problem documents; anything else (a network failure) isn't + error: (error: HttpErrorResponse) => { + this.problem = error.error?.title + ? error.error + : { type: 'about:blank', title: 'The job could not be submitted', status: error.status }; + }, + }); } } +/** FASTA text to the sample tool's sequences: one per record, id from the header. */ +export function fromFasta(text: string): ToolInput['sequences'] { + return text.split('>').map((record) => record.trim()).filter(Boolean).map((record) => { + const [header, ...lines] = record.split('\n'); + return { id: header.trim(), sequence: lines.join('').replace(/\s/g, '') }; + }); +} + +export function toFasta(inputs: ToolInput): string { + return inputs.sequences.map(({ id, sequence }) => `>${id}\n${sequence}`).join('\n'); +} + /** JSON with keys sorted at every level, so two equal payloads compare equal regardless of key order. */ function stableStringify(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; diff --git a/src/app/models/job-result.spec.ts b/src/app/models/job-result.spec.ts index d58b56e..12829d4 100644 --- a/src/app/models/job-result.spec.ts +++ b/src/app/models/job-result.spec.ts @@ -2,7 +2,7 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { of } from 'rxjs'; import { JobResult } from './job-result'; -import { JobStatus, JobType } from '~/app/api/mmli-backend/v1'; +import { JobStatus } from '~/app/models/v1-job'; /** * The polling lifecycle: one shared poll, the result fetched exactly once when the @@ -10,37 +10,50 @@ import { JobStatus, JobType } from '~/app/api/mmli-backend/v1'; */ const DELAY_MS = 1000; // the delay() applied to the result in jobResultResponse$ -function makeJobResult(statuses: { phase: JobStatus }[], resultValue: any = {}) { +function makeJobResult(statuses: JobStatus[], resultValue: any = {}) { let call = 0; const service: any = { - getResultStatus: jasmine.createSpy('getResultStatus') - .and.callFake(() => of({ ...statuses[Math.min(call++, statuses.length - 1)] })), - getResult: jasmine.createSpy('getResult').and.returnValue(of(resultValue)), + getJob: jasmine.createSpy('getJob') + .and.callFake(() => of({ status: statuses[Math.min(call++, statuses.length - 1)] })), + getResults: jasmine.createSpy('getResults').and.returnValue(of(resultValue)), }; const jr = new JobResult(service); - jr.jobType = 'test' as JobType; jr.jobId = 'job-1'; return { jr, service }; } describe('JobResult polling lifecycle', () => { it('clears loading and fetches the result exactly once when the job completes', fakeAsync(() => { - const { jr, service } = makeJobResult([{ phase: JobStatus.Processing }, { phase: JobStatus.Completed }]); + const { jr, service } = makeJobResult(['queued', 'processing', 'completed']); let loading = true; jr.isLoading$.subscribe((v) => (loading = v)); jr.jobResultResponse$.subscribe(); - tick(0); // poll 1: processing + tick(0); // poll 1: queued expect(loading).toBeTrue(); - tick(10000); // poll 2: completed, fetch + tick(10000); // poll 2: processing + expect(service.getResults).not.toHaveBeenCalled(); + tick(10000); // poll 3: completed, fetch tick(DELAY_MS + 1); - expect(service.getResult).toHaveBeenCalledTimes(1); + expect(service.getResults).toHaveBeenCalledTimes(1); expect(loading).toBeFalse(); })); + it('stops polling and fetches nothing when the job is canceled', fakeAsync(() => { + const { jr, service } = makeJobResult(['queued', 'canceled']); + jr.jobResultResponse$.subscribe(); + + tick(0); + tick(10000); + tick(30000); + + expect(service.getJob).toHaveBeenCalledTimes(2); + expect(service.getResults).not.toHaveBeenCalled(); + })); + it('keeps loading and does not fetch a result when the job errors', fakeAsync(() => { - const { jr, service } = makeJobResult([{ phase: JobStatus.Processing }, { phase: JobStatus.Error }]); + const { jr, service } = makeJobResult(['processing', 'error']); let loading = true; jr.isLoading$.subscribe((v) => (loading = v)); jr.jobResultResponse$.subscribe(); @@ -49,12 +62,12 @@ describe('JobResult polling lifecycle', () => { tick(10000); tick(DELAY_MS + 1); - expect(service.getResult).not.toHaveBeenCalled(); + expect(service.getResults).not.toHaveBeenCalled(); expect(loading).toBeTrue(); })); it('handles an already-completed first poll', fakeAsync(() => { - const { jr, service } = makeJobResult([{ phase: JobStatus.Completed }]); + const { jr, service } = makeJobResult(['completed']); let loading = true; jr.isLoading$.subscribe((v) => (loading = v)); jr.jobResultResponse$.subscribe(); @@ -62,7 +75,7 @@ describe('JobResult polling lifecycle', () => { tick(0); tick(DELAY_MS + 1); - expect(service.getResult).toHaveBeenCalledTimes(1); + expect(service.getResults).toHaveBeenCalledTimes(1); expect(loading).toBeFalse(); })); @@ -71,7 +84,7 @@ describe('JobResult polling lifecycle', () => { // pipeline all read the status. // Before the stream was shared, each started its own timer and its own // requests, and the result gate could miss the terminal status. - const { jr, service } = makeJobResult([{ phase: JobStatus.Completed }]); + const { jr, service } = makeJobResult(['completed']); jr.statusResponse$.subscribe(); jr.statusResponse$.subscribe(); jr.jobResultResponse$.subscribe(); @@ -79,7 +92,7 @@ describe('JobResult polling lifecycle', () => { tick(0); tick(DELAY_MS + 1); - expect(service.getResultStatus).toHaveBeenCalledTimes(1); - expect(service.getResult).toHaveBeenCalledTimes(1); + expect(service.getJob).toHaveBeenCalledTimes(1); + expect(service.getResults).toHaveBeenCalledTimes(1); })); }); diff --git a/src/app/models/job-result.ts b/src/app/models/job-result.ts index 62495f2..dbf9705 100644 --- a/src/app/models/job-result.ts +++ b/src/app/models/job-result.ts @@ -1,10 +1,9 @@ -import { timer, take, switchMap, tap, takeWhile, BehaviorSubject, filter, map, delay, shareReplay } from "rxjs"; -import { JobStatus, JobType } from "~/app/api/mmli-backend/v1"; +import { timer, take, switchMap, tap, takeWhile, BehaviorSubject, filter, delay, shareReplay } from "rxjs"; import { TestService } from '~/app/services/test.service'; +import { TERMINAL_STATUSES } from '~/app/models/v1-job'; export class JobResult { jobId: string; - jobType: JobType; isLoading$ = new BehaviorSubject(true); //TODO: change it to true resultLoaded$ = new BehaviorSubject(false); @@ -21,15 +20,9 @@ export class JobResult { * refCount lets the poll stop when nothing is listening. */ statusResponse$ = timer(0, 10000).pipe( - switchMap(() => this.service.getResultStatus( - this.jobType, - this.jobId, - )), + switchMap(() => this.service.getJob(this.jobId)), tap(() => this.resultLoaded$.value ? null : this.isLoading$.next(true)), - takeWhile((data) => - data.phase === JobStatus.Processing - || data.phase === JobStatus.Queued - , true), + takeWhile((job) => !TERMINAL_STATUSES.includes(job.status), true), tap((data) => { console.log('job status: ', data) }), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -37,9 +30,9 @@ export class JobResult { // Completion is read off the shared poll (filter + take(1)), so the result is // fetched exactly once and no second poll can miss the terminal status. jobResultResponse$ = this.statusResponse$.pipe( - filter((job) => job.phase === JobStatus.Completed), + filter((job) => job.status === 'completed'), take(1), - switchMap(() => this.service.getResult(this.jobType, this.jobId)), + switchMap(() => this.service.getResults(this.jobId)), delay(1000), tap(() => this.isLoading$.next(false)), tap(() => this.resultLoaded$.next(true)), @@ -50,4 +43,4 @@ export class JobResult { constructor( public service: TestService, ) { } -} \ No newline at end of file +} diff --git a/src/app/models/v1-job.ts b/src/app/models/v1-job.ts new file mode 100644 index 0000000..d784d61 --- /dev/null +++ b/src/app/models/v1-job.ts @@ -0,0 +1,40 @@ +/** + * The /v1 job resource and results envelope, as mmli-backend renders them + * (`_job_document` and `get_results` in app/routers/v1/jobs.py). + * + * Written by hand: /v1's OpenAPI declares no response schemas, so the generated + * client types every response as `any`. + */ +export type JobStatus = 'queued' | 'processing' | 'completed' | 'error' | 'canceled'; + +/** Statuses after which the job never changes again. */ +export const TERMINAL_STATUSES: readonly JobStatus[] = ['completed', 'error', 'canceled']; + +export interface Job { + job_id: string; + tool: string; + status: JobStatus; + parent_job_id: string | null; + /** ISO 8601, UTC */ + submitted_at: string | null; + started_at: string | null; + finished_at: string | null; + /** The input document as submitted */ + inputs: unknown; +} + +export interface JobResults { + job_id: string; + tool: string; + results: T; +} + +/** An RFC 9457 problem document; every /v1 error is one. */ +export interface Problem { + type: string; + title: string; + status: number; + detail?: string; + /** For invalid input: a JSON Pointer into the submitted document, and what is wrong there */ + errors?: { pointer: string; detail: string }[]; +} diff --git a/src/app/param-change-route-reuse.strategy.spec.ts b/src/app/param-change-route-reuse.strategy.spec.ts index 8a71f62..bb4c472 100644 --- a/src/app/param-change-route-reuse.strategy.spec.ts +++ b/src/app/param-change-route-reuse.strategy.spec.ts @@ -72,14 +72,14 @@ describe('ParamChangeRouteReuseStrategy', () => { // A's component, which had read its job id once from a snapshot. it('gives the result page a fresh component for a new job id', async () => { const harness = await setup( - [{ path: '', component: LayoutStubComponent, children: [{ path: 'test/result/:id', component: TestResultComponent }] }], + [{ path: '', component: LayoutStubComponent, children: [{ path: 'result/:id', component: TestResultComponent }] }], [provideNoopAnimations(), provideEnvironmentServiceStub(), provideHttpClient(), provideHttpClientTesting()], ); - await harness.navigateByUrl('/test/result/A', LayoutStubComponent); + await harness.navigateByUrl('/result/A', LayoutStubComponent); const first = childInstance(harness, TestResultComponent); expect(first.jobId).toBe('A'); - await harness.navigateByUrl('/test/result/B', LayoutStubComponent); + await harness.navigateByUrl('/result/B', LayoutStubComponent); const second = childInstance(harness, TestResultComponent); expect(second).not.toBe(first); diff --git a/src/app/param-change-route-reuse.strategy.ts b/src/app/param-change-route-reuse.strategy.ts index 69d8285..7f0a81e 100644 --- a/src/app/param-change-route-reuse.strategy.ts +++ b/src/app/param-change-route-reuse.strategy.ts @@ -5,7 +5,7 @@ import { ActivatedRouteSnapshot, BaseRouteReuseStrategy, Params } from '@angular * Recreate a routed component when its path params change. * * Angular's default strategy reuses a component whenever the route config is the - * same, so navigating from `test/result/A` to `test/result/B` kept the result + * same, so navigating from `result/A` to `result/B` kept the result * component built for A: it read its job id from a snapshot once, and everything * it holds still described A. Comparing params here gives B a fresh instance, * exactly as opening the URL directly would. diff --git a/src/app/services/environment.service.ts b/src/app/services/environment.service.ts index 18c2803..b600852 100644 --- a/src/app/services/environment.service.ts +++ b/src/app/services/environment.service.ts @@ -3,17 +3,23 @@ import { Injectable } from '@angular/core'; import { lastValueFrom } from 'rxjs'; import { EnvVars } from "../models/envvars"; import {Configuration} from "../api/mmli-backend/v1"; +import {Configuration as V1Configuration} from "@api/v1"; @Injectable() export class EnvironmentService { private envConfig: EnvVars; - constructor(private readonly http: HttpClient, private apiConfig: Configuration) {} + constructor( + private readonly http: HttpClient, + private apiConfig: Configuration, + private v1ApiConfig: V1Configuration, + ) {} async loadEnvConfig(configPath: string): Promise { console.log('Loading environment config!'); this.envConfig = await lastValueFrom(this.http.get(configPath)); this.apiConfig.basePath = this.envConfig.hostname + this.envConfig.basePath + this.v1ApiConfig.basePath = this.envConfig.hostname + this.envConfig.basePath + '/v1'; } getEnvConfig(): EnvVars { diff --git a/src/app/services/test.examples.ts b/src/app/services/test.examples.ts new file mode 100644 index 0000000..f71eb47 --- /dev/null +++ b/src/app/services/test.examples.ts @@ -0,0 +1,31 @@ +import { ToolResults } from "@api/tool-schemas"; +import { Job } from "~/app/models/v1-job"; + +// What frontendOnly mode shows instead of calling the backend. TODO: replace with an +// example job and results for your tool. These are the sample tool's, from +// moleculemaker/job-template examples/. +export const EXAMPLE_JOB: Job = { + job_id: 'example-id', + tool: 'job-template-sample', + status: 'completed', + parent_job_id: null, + submitted_at: '2026-10-20T12:00:00Z', + started_at: null, + finished_at: '2026-10-20T12:01:00Z', + inputs: { + sequences: [ + { id: 'insulin-b', sequence: 'FVNQHLCGSHLVEALYLVCGERGFFYTPKT' }, + { id: 'glucagon', sequence: 'HSQGTFTSDYSKYLDSRRAQDFVQWLMNT' }, + ], + }, +}; + +export const EXAMPLE_RESULTS: ToolResults = { + rows: [ + { id: 'insulin-b', length: 30, molecular_weight_da: 3429.96 }, + { id: 'glucagon', length: 29, molecular_weight_da: 3482.79 }, + ], + artifacts: [ + { name: 'results.csv', media_type: 'text/csv', description: 'All rows as CSV' }, + ], +}; diff --git a/src/app/services/test.service.spec.ts b/src/app/services/test.service.spec.ts new file mode 100644 index 0000000..4bc89c2 --- /dev/null +++ b/src/app/services/test.service.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; + +import { provideApi } from '@api/v1'; +import { EnvironmentService } from './environment.service'; +import { TestService } from './test.service'; + +const API = 'https://backend.test/v1'; +const INPUTS = { sequences: [{ id: 'a', sequence: 'ACD' }] }; + +describe('TestService', () => { + let service: TestService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideApi({ basePath: API }), + { provide: EnvironmentService, useValue: { getEnvConfig: () => ({ frontendOnly: 'false' }) } }, + ], + }); + service = TestBed.inject(TestService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + describe('submit', () => { + it('posts multipart to the app\'s tool: the inputs as JSON text, then each file', () => { + const file = new File(['>a\nACD'], 'extra.fasta'); + service.submit(INPUTS, [file], '').subscribe(); + + const request = http.expectOne(`${API}/tools/${service.tool}/jobs`); + expect(request.request.method).toBe('POST'); + const body = request.request.body as FormData; + expect(JSON.parse(body.get('inputs') as string)).toEqual(INPUTS); + expect((body.getAll('files') as File[]).map((f) => f.name)).toEqual(['extra.fasta']); + // Left unset, so the browser writes the multipart boundary into it + expect(request.request.headers.has('Content-Type')).toBeFalse(); + request.flush({ job_id: 'j1', status: 'queued' }); + }); + + it('sends the notification address as a header, so the body stays the input document', () => { + service.submit(INPUTS, [], 'a@b.c').subscribe(); + + const request = http.expectOne(`${API}/tools/${service.tool}/jobs`); + expect(request.request.headers.get('X-Notify-Email')).toBe('a@b.c'); + expect((request.request.body as FormData).has('email')).toBeFalse(); + request.flush({ job_id: 'j1', status: 'queued' }); + }); + + it('sends no header without an address', () => { + service.submit(INPUTS).subscribe(); + + const request = http.expectOne(`${API}/tools/${service.tool}/jobs`); + expect(request.request.headers.has('X-Notify-Email')).toBeFalse(); + request.flush({ job_id: 'j1', status: 'queued' }); + }); + }); + + it('reads the job from /v1/jobs/:id', () => { + let status = ''; + service.getJob('j1').subscribe((job) => (status = job.status)); + + http.expectOne(`${API}/jobs/j1`).flush({ job_id: 'j1', status: 'processing' }); + expect(status).toBe('processing'); + }); + + it('unwraps the results envelope', () => { + let results: unknown; + service.getResults('j1').subscribe((r) => (results = r)); + + http.expectOne(`${API}/jobs/j1/results`).flush({ job_id: 'j1', tool: 't', results: { rows: [], artifacts: [] } }); + expect(results).toEqual({ rows: [], artifacts: [] }); + }); + + it('links artifacts under the job', () => { + expect(service.artifactUrl('j1', 'results.csv')).toBe(`${API}/jobs/j1/artifacts/results.csv`); + }); +}); diff --git a/src/app/services/test.service.ts b/src/app/services/test.service.ts index 6946305..8e7657a 100644 --- a/src/app/services/test.service.ts +++ b/src/app/services/test.service.ts @@ -1,15 +1,13 @@ import { Injectable } from "@angular/core"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; import { Observable, map, of } from "rxjs"; -import { BodyCreateJobJobTypeJobsPost, FilesService, Job, JobType, JobsService } from "../api/mmli-backend/v1"; +import { JobsService } from "@api/v1"; +import { ToolInput, ToolResults } from "@api/tool-schemas"; +import { APP_IDENTITY } from "~/app/app-identity"; +import { Job, JobResults } from "~/app/models/v1-job"; import { EnvironmentService } from "./environment.service"; - -// import { TestService as TestApiService } from "../api/mmli-backend/v1"; // TODO: use the correct service -// import exampleStatus from '../../assets/example_status.json'; -// import example from '../../assets/example.json'; - -const exampleStatus: any = "WARNING: please provide your own example_status.json"; -const example: any = "WARNING: please provide your own example.json"; +import { EXAMPLE_JOB, EXAMPLE_RESULTS } from "./test.examples"; @Injectable({ providedIn: "root", @@ -17,53 +15,63 @@ const example: any = "WARNING: please provide your own example.json"; export class TestService { frontendOnly = false; + /** The mmli-backend tool this app submits to (app.yaml `job_type`). */ + readonly tool = APP_IDENTITY.jobType; + constructor( + private http: HttpClient, private jobsService: JobsService, - private filesService: FilesService, private environmentService: EnvironmentService, - - // private apiService: TestApiService, ) { this.frontendOnly = this.environmentService.getEnvConfig().frontendOnly === "true"; } - createAndRunJob(jobType: JobType, requestBody: BodyCreateJobJobTypeJobsPost): Observable { + /** + * Submit a job: the input document plus any files the tool reads from its input + * directory. Always multipart, which /v1 accepts with or without files. + * + * Not through the generated client: the endpoint accepts JSON or multipart, and the + * generator emits a JSON-only method for it. + */ + submit(inputs: ToolInput, files: File[] = [], email = ''): Observable { if (this.frontendOnly) { - return of(exampleStatus as any); + return of(EXAMPLE_JOB); + } + const body = new FormData(); + body.append('inputs', JSON.stringify(inputs)); + for (const file of files) { + body.append('files', file, file.name); } - return this.jobsService.createJobJobTypeJobsPost(jobType, requestBody); + // The address goes in a header so the body stays exactly the tool's input schema + let headers = new HttpHeaders(); + if (email) { + headers = headers.set('X-Notify-Email', email); + } + return this.http.post(`${this.basePath}/tools/${this.tool}/jobs`, body, { headers }); } - getResultStatus(jobType: JobType, jobID: string): Observable { - if (this.frontendOnly) { - return of(exampleStatus as any); - } - return this.jobsService.listJobsByTypeAndJobIdJobTypeJobsJobIdGet(jobType, jobID) - .pipe(map((jobs) => jobs[0])); + /** /v1's base URL, which EnvironmentService sets from envvars.json. */ + private get basePath(): string { + return this.jobsService.configuration.basePath!; } - getResult(jobType: JobType, jobID: string): Observable { + getJob(jobId: string): Observable { if (this.frontendOnly) { - return of(example as any); + return of(EXAMPLE_JOB); } - return this.filesService.getResultsBucketNameResultsJobIdGet(jobType, jobID); + return this.jobsService.getJobJobsJobIdGet(jobId); } - getError(jobType: JobType, jobID: string): Observable { + getResults(jobId: string): Observable { if (this.frontendOnly) { - return of('error'); + return of(EXAMPLE_RESULTS); } - return this.filesService.getErrorsBucketNameErrorsJobIdGet(jobType, jobID); + return this.jobsService.getResultsJobsJobIdResultsGet(jobId) + .pipe(map((response: JobResults) => response.results)); } - updateSubscriberEmail(jobType: JobType, jobId: string, email: string) { - if (this.frontendOnly) { - return of(exampleStatus as any); - } - return this.jobsService.patchExistingJobJobTypeJobsJobIdRunIdPatch(jobType, { - job_id: jobId, - run_id: 0, - email: email, - }); + /** Where the backend serves one of the job's output files. */ + artifactUrl(jobId: string, name: string): string { + return `${this.basePath}/jobs/${jobId}/artifacts/${encodeURIComponent(name)}`; } } diff --git a/src/main.ts b/src/main.ts index b6e0f0e..762e543 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatomoModule, MatomoRouterModule } from 'ngx-matomo-client'; import { MenuModule } from 'primeng/menu'; import { ApiModule, Configuration } from '@api/mmli-backend/v1'; +import { provideApi as provideV1Api } from '@api/v1'; import { DialogModule } from 'primeng/dialog'; import { AppComponent } from './app/app.component'; import { provideStore } from '@ngrx/store'; @@ -49,6 +50,8 @@ bootstrapApplication(AppComponent, { DialogModule, AppRoutingModule, ), + // basePath is set from envvars.json by EnvironmentService + provideV1Api({}), EnvironmentService, provideAppInitializer(() => { const initializerFn = (initAppFn)(inject(EnvironmentService)); diff --git a/src/stories/storybook.config.ts b/src/stories/storybook.config.ts index 98f6c2a..2e5ecc9 100644 --- a/src/stories/storybook.config.ts +++ b/src/stories/storybook.config.ts @@ -4,109 +4,35 @@ import { provideRouter, RouterModule } from '@angular/router'; // Import all commonly used services and modules import { TestService } from '../app/services/test.service'; -import { BodyCreateJobJobTypeJobsPost, Job, JobType } from '../app/api/mmli-backend/v1'; +import { EXAMPLE_JOB, EXAMPLE_RESULTS } from '../app/services/test.examples'; +import { Job, JobStatus } from '../app/models/v1-job'; +import { ToolResults } from '../app/api/tool-schemas'; import { Observable, of } from 'rxjs'; -// fixtures -import errorJobStatus from './fixtures/job-status--error.json'; -import processingJobStatus from './fixtures/job-status--processing.json'; -import queuedJobStatus from './fixtures/job-status--queued.json'; -import completedJobStatus from './fixtures/job-status--completed.json'; -import errorJobResult from './fixtures/job-result--default.json'; -import completedJobResult from './fixtures/job-result--default.json'; +type TestCase = Extract; -// Define a strategy interface -interface TestServiceStrategy { - createAndRunJob(jobType: JobType, requestBody: BodyCreateJobJobTypeJobsPost): Observable; - getResultStatus(jobType: JobType, jobID: string): Observable; - getResult(jobType: JobType, jobID: string): Observable; -} - -// Implement concrete strategies -class QueuedTestStrategy implements TestServiceStrategy { - createAndRunJob(): Observable { - return of(queuedJobStatus as Job); - } - getResultStatus(): Observable { - return this.createAndRunJob(); - } - getResult(): Observable { - return of({}); - } -} - -class ErrorTestStrategy implements TestServiceStrategy { - createAndRunJob(): Observable { - return of(errorJobStatus as Job); - } - getResultStatus(): Observable { - return this.createAndRunJob(); - } - getResult(): Observable { - return of(errorJobResult); - } -} - -class ProcessingTestStrategy implements TestServiceStrategy { - createAndRunJob(): Observable { - return of(processingJobStatus as Job); - } - getResultStatus(): Observable { - return this.createAndRunJob(); - } - getResult(): Observable { - return of({}); - } -} - -class CompletedTestStrategy implements TestServiceStrategy { - createAndRunJob(): Observable { - return of(completedJobStatus as Job); - } - getResultStatus(): Observable { - return this.createAndRunJob(); - } - getResult(): Observable { - return of(completedJobResult); - } -} - -// Factory method to create strategies -function createTestStrategy(testCase: 'queued' | 'error' | 'processing' | 'completed'): TestServiceStrategy { - switch (testCase) { - case 'error': - return new ErrorTestStrategy(); - case 'processing': - return new ProcessingTestStrategy(); - case 'completed': - return new CompletedTestStrategy(); - default: - return new QueuedTestStrategy(); - } -} - -// Updated MockTestService +// A TestService whose job is always in one status, with the example results once completed export class MockTestService { - private strategy: TestServiceStrategy; + constructor(private testCase: TestCase = 'queued') { } - constructor(testCase: 'queued' | 'error' | 'processing' | 'completed' = 'queued') { - this.strategy = createTestStrategy(testCase); + setTestCase(testCase: TestCase) { + this.testCase = testCase; } - setTestCase(testCase: 'queued' | 'error' | 'processing' | 'completed') { - this.strategy = createTestStrategy(testCase); + submit(): Observable { + return this.getJob(); } - createAndRunJob(jobType: JobType, requestBody: BodyCreateJobJobTypeJobsPost): Observable { - return this.strategy.createAndRunJob(jobType, requestBody); + getJob(): Observable { + return of({ ...EXAMPLE_JOB, status: this.testCase }); } - getResultStatus(jobType: JobType, jobID: string): Observable { - return this.strategy.getResultStatus(jobType, jobID); + getResults(): Observable { + return of(EXAMPLE_RESULTS); } - getResult(jobType: JobType, jobID: string): Observable { - return this.strategy.getResult(jobType, jobID); + artifactUrl(jobId: string, name: string): string { + return `#${jobId}/${name}`; } } From 59e64628d2929c18eb47ee0d29bf80b8d1ec3d41 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:30:52 -0500 Subject: [PATCH 03/12] fix: result page stops polling when destroyed and shows load errors - jobResultResponse$'s shareReplay had no refCount, so it kept the status poll subscribed after the page was gone (rxjs 7.8 defaults refCount to false). The new test destroys the page and asserts no further getJob calls, without discardPeriodicTasks. - A getJob or getResults error (404 unknown job, 409 JOB_FAILED, 502 invalid results) left the page on "Processing" forever. Transient errors (network, 502/503/504) are retried twice; anything else shows the problem document. - A job from another tool (/v1 ids are global) shows a problem instead of rendering inputs this page can't read. --- .../test-result/test-result.component.html | 9 +- .../test-result/test-result.errors.spec.ts | 89 +++++++++++++++++++ .../test-result/test-result.polling.spec.ts | 23 ++++- src/app/models/job-result.spec.ts | 2 +- src/app/models/job-result.ts | 45 +++++++++- 5 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 src/app/components/test-result/test-result.errors.spec.ts diff --git a/src/app/components/test-result/test-result.component.html b/src/app/components/test-result/test-result.component.html index 42bf776..5d05459 100644 --- a/src/app/components/test-result/test-result.component.html +++ b/src/app/components/test-result/test-result.component.html @@ -29,7 +29,14 @@
- @if (isLoading$ | async) { + @if (loadProblem$ | async; as problem) { +
+
{{ problem.title }}
+ @if (problem.detail) { +

{{ problem.detail }}

+ } +
+ } @else if (isLoading$ | async) { } diff --git a/src/app/components/test-result/test-result.errors.spec.ts b/src/app/components/test-result/test-result.errors.spec.ts new file mode 100644 index 0000000..1cc26cc --- /dev/null +++ b/src/app/components/test-result/test-result.errors.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideHttpClient, HttpErrorResponse } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { TestService } from '~/app/services/test.service'; +import { provideEnvironmentServiceStub } from '~/app/testing/environment-service.stub'; +import { TestResultComponent } from './test-result.component'; + +/** A failed request, as HttpClient reports it: /v1 errors carry a problem document. */ +function httpError(status: number, title?: string) { + return new HttpErrorResponse({ + status, + error: title ? { type: 'about:blank', title, status } : null, + }); +} + +describe('TestResultComponent when a request fails', () => { + let service: jasmine.SpyObj; + let fixture: ComponentFixture; + + function create() { + TestBed.configureTestingModule({ + imports: [TestResultComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([]), + provideEnvironmentServiceStub(), { provide: TestService, useValue: service }], + }); + fixture = TestBed.createComponent(TestResultComponent); + fixture.detectChanges(); + } + + const shown = (testId: string) => + fixture.nativeElement.querySelector(`[data-testid="${testId}"]`) as HTMLElement | null; + + beforeEach(() => { + service = jasmine.createSpyObj('TestService', ['getJob', 'getResults']); + }); + + it('shows the problem instead of "Processing" when the job does not exist', fakeAsync(() => { + service.getJob.and.returnValue(throwError(() => httpError(404, 'Unknown job'))); + create(); + tick(0); + fixture.detectChanges(); + + expect(shown('load-error')?.textContent).toContain('Unknown job'); + expect(shown('job-processing')).toBeNull(); + fixture.destroy(); + })); + + it('keeps polling through a brief outage, such as a deploy', fakeAsync(() => { + let calls = 0; + service.getJob.and.callFake(() => ++calls === 1 + ? throwError(() => httpError(502)) + : of({ job_id: 'j1', tool: 'job-template-sample', status: 'processing' } as any)); + create(); + tick(0); + tick(10000); + fixture.detectChanges(); + + expect(shown('load-error')).toBeNull(); + expect(shown('job-processing')).not.toBeNull(); + fixture.destroy(); + })); + + it("says so instead of rendering another tool's job, whose inputs aren't this tool's", fakeAsync(() => { + service.getJob.and.returnValue(of({ job_id: 'j1', tool: 'somn', status: 'processing', inputs: {} } as any)); + create(); + tick(0); + fixture.detectChanges(); + + expect(shown('load-error')?.textContent).toContain('another tool'); + expect(shown('job-processing')).toBeNull(); + fixture.destroy(); + })); + + it('shows the problem when a completed job has no usable results', fakeAsync(() => { + service.getJob.and.returnValue(of({ job_id: 'j1', tool: 'job-template-sample', status: 'completed' } as any)); + service.getResults.and.returnValue(throwError(() => httpError(502, 'Results do not match the output schema'))); + create(); + tick(0); + tick(1000); + fixture.detectChanges(); + + expect(shown('load-error')?.textContent).toContain('Results do not match the output schema'); + expect(shown('job-processing')).toBeNull(); + fixture.destroy(); + })); +}); diff --git a/src/app/components/test-result/test-result.polling.spec.ts b/src/app/components/test-result/test-result.polling.spec.ts index abf2b0b..f97049a 100644 --- a/src/app/components/test-result/test-result.polling.spec.ts +++ b/src/app/components/test-result/test-result.polling.spec.ts @@ -15,7 +15,7 @@ import { TestResultComponent } from './test-result.component'; describe('TestResultComponent status polling', () => { it('makes one status request per poll while the loading screen is shown', fakeAsync(() => { const service = jasmine.createSpyObj('TestService', ['getJob', 'getResults']); - service.getJob.and.callFake(() => of({ status: 'processing' } as any)); + service.getJob.and.callFake(() => of({ tool: 'job-template-sample', status: 'processing' } as any)); TestBed.configureTestingModule({ imports: [TestResultComponent], @@ -37,4 +37,25 @@ describe('TestResultComponent status polling', () => { fixture.destroy(); discardPeriodicTasks(); })); + + it('stops polling once the page is destroyed', fakeAsync(() => { + const service = jasmine.createSpyObj('TestService', ['getJob', 'getResults']); + service.getJob.and.callFake(() => of({ tool: 'job-template-sample', status: 'processing' } as any)); + + TestBed.configureTestingModule({ + imports: [TestResultComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([]), + provideEnvironmentServiceStub(), { provide: TestService, useValue: service }], + }); + const fixture: ComponentFixture = TestBed.createComponent(TestResultComponent); + fixture.detectChanges(); + tick(0); + const callsBeforeDestroy = service.getJob.calls.count(); + + fixture.destroy(); + tick(30000); + + // No discardPeriodicTasks() here: a timer still running would also fail the test. + expect(service.getJob).toHaveBeenCalledTimes(callsBeforeDestroy); + })); }); diff --git a/src/app/models/job-result.spec.ts b/src/app/models/job-result.spec.ts index 12829d4..c960a6f 100644 --- a/src/app/models/job-result.spec.ts +++ b/src/app/models/job-result.spec.ts @@ -14,7 +14,7 @@ function makeJobResult(statuses: JobStatus[], resultValue: any = {}) { let call = 0; const service: any = { getJob: jasmine.createSpy('getJob') - .and.callFake(() => of({ status: statuses[Math.min(call++, statuses.length - 1)] })), + .and.callFake(() => of({ tool: 'job-template-sample', status: statuses[Math.min(call++, statuses.length - 1)] })), getResults: jasmine.createSpy('getResults').and.returnValue(of(resultValue)), }; const jr = new JobResult(service); diff --git a/src/app/models/job-result.ts b/src/app/models/job-result.ts index dbf9705..0f3d99b 100644 --- a/src/app/models/job-result.ts +++ b/src/app/models/job-result.ts @@ -1,6 +1,13 @@ -import { timer, take, switchMap, tap, takeWhile, BehaviorSubject, filter, delay, shareReplay } from "rxjs"; +import { timer, take, switchMap, tap, takeWhile, BehaviorSubject, filter, delay, shareReplay, catchError, retry, throwError, map, EMPTY, Observable } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; import { TestService } from '~/app/services/test.service'; -import { TERMINAL_STATUSES } from '~/app/models/v1-job'; +import { APP_IDENTITY } from '~/app/app-identity'; +import { Problem, TERMINAL_STATUSES } from '~/app/models/v1-job'; + +/** A network failure or a gateway error during a deploy: worth asking again. */ +function isTransient(error: HttpErrorResponse): boolean { + return error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504; +} export class JobResult { jobId: string; @@ -8,6 +15,18 @@ export class JobResult { isLoading$ = new BehaviorSubject(true); //TODO: change it to true resultLoaded$ = new BehaviorSubject(false); + /** Why the job or its results could not be loaded; the page shows it instead of the loading screen. */ + loadProblem$ = new BehaviorSubject(null); + + /** Stop polling and show why. /v1 errors are problem documents; anything else isn't. */ + private fail = (error: HttpErrorResponse): Observable => { + this.loadProblem$.next(error.error?.title + ? error.error + : { type: 'about:blank', title: 'The job could not be loaded', status: error.status }); + this.isLoading$.next(false); + return EMPTY; + }; + /** * One shared poll: every consumer (template `| async`, jobResultResponse$, and * , which receives this stream as its status$ input) rides this @@ -20,7 +39,23 @@ export class JobResult { * refCount lets the poll stop when nothing is listening. */ statusResponse$ = timer(0, 10000).pipe( - switchMap(() => this.service.getJob(this.jobId)), + // Ask again a couple of times on a transient error; anything else ends the poll + switchMap(() => this.service.getJob(this.jobId).pipe( + retry({ count: 2, delay: (error) => isTransient(error) ? timer(5000) : throwError(() => error) }), + )), + // /v1 job ids are global, so /result/ can name another tool's job, whose + // inputs and results this page can't render + map((job) => { + if (job.tool !== APP_IDENTITY.jobType) { + throw new HttpErrorResponse({ status: 404, error: { + type: 'about:blank', status: 404, + title: 'This job belongs to another tool', + detail: `Job ${job.job_id} was run by ${job.tool}.`, + } }); + } + return job; + }), + catchError(this.fail), tap(() => this.resultLoaded$.value ? null : this.isLoading$.next(true)), takeWhile((job) => !TERMINAL_STATUSES.includes(job.status), true), tap((data) => { console.log('job status: ', data) }), @@ -33,11 +68,13 @@ export class JobResult { filter((job) => job.status === 'completed'), take(1), switchMap(() => this.service.getResults(this.jobId)), + catchError(this.fail), delay(1000), tap(() => this.isLoading$.next(false)), tap(() => this.resultLoaded$.next(true)), tap((data) => { console.log('result: ', data) }), - shareReplay(1), + // refCount, or this keeps the status poll above subscribed after the page is gone + shareReplay({ bufferSize: 1, refCount: true }), ); constructor( From 74551417ee082af6a9ffc6c4813bd980d5e5e9d5 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:30:53 -0500 Subject: [PATCH 04/12] fix: ignore a second submit while one is in flight; don't navigate after leaving Submit is disabled and shows a spinner until the backend answers, and is enabled again after a refusal. The submit subscription ends with the component, so a late response no longer pulls the user to /result/. --- src/app/components/test/test.component.html | 2 +- .../components/test/test.component.spec.ts | 38 ++++++++++++++++++- src/app/components/test/test.component.ts | 16 +++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/app/components/test/test.component.html b/src/app/components/test/test.component.html index ac92083..c59215a 100644 --- a/src/app/components/test/test.component.html +++ b/src/app/components/test/test.component.html @@ -68,7 +68,7 @@
Leave an email to get an notification when y
+ [disabled]="form.invalid || submitting" [loading]="submitting" iconPos="right" icon="pi pi-arrow-right"> @if (requestUnchanged) { diff --git a/src/app/components/test/test.component.spec.ts b/src/app/components/test/test.component.spec.ts index df1b19b..fcb7141 100644 --- a/src/app/components/test/test.component.spec.ts +++ b/src/app/components/test/test.component.spec.ts @@ -4,7 +4,7 @@ import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter, Router } from '@angular/router'; -import { of, throwError } from 'rxjs'; +import { of, throwError, Subject } from 'rxjs'; import { LoadedJob, TestComponent, fromFasta, toFasta } from './test.component'; import { TestService } from '~/app/services/test.service'; @@ -52,6 +52,42 @@ describe('TestComponent', () => { expect(submit).toHaveBeenCalledWith({ sequences: fromFasta(FASTA) }, [file], 'a@b.c'); }); + describe('while a submission is in flight', () => { + let response: Subject; + + beforeEach(() => { + response = new Subject(); + submit.and.returnValue(response); + }); + + // Through the form's (ngSubmit) binding, as a double click would arrive + const pressSubmit = () => + fixture.nativeElement.querySelector('form').dispatchEvent(new Event('submit')); + + it('ignores a second submit, so a double click creates one job', () => { + pressSubmit(); + pressSubmit(); + + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('allows submitting again after the backend refuses the input', () => { + pressSubmit(); + response.error(new HttpErrorResponse({ status: 422, error: { title: 'Invalid input', status: 422 } })); + pressSubmit(); + + expect(submit).toHaveBeenCalledTimes(2); + }); + + it('does not navigate to the job if the user has left the page', () => { + pressSubmit(); + fixture.destroy(); + response.next({ job_id: 'job-b' } as Job); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + it('emits the created job id once the submission resolves, then navigates to it', () => { component.onSubmit(); diff --git a/src/app/components/test/test.component.ts b/src/app/components/test/test.component.ts index 5139ed3..c067067 100644 --- a/src/app/components/test/test.component.ts +++ b/src/app/components/test/test.component.ts @@ -1,4 +1,6 @@ -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; +import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from "@angular/forms"; import { HttpErrorResponse } from "@angular/common/http"; import { Router } from "@angular/router"; @@ -60,6 +62,11 @@ export class TestComponent implements OnChanges { currentPage = 'input'; + /** True from submit until the backend answers; a second submit meanwhile is ignored. */ + submitting = false; + + private destroyRef = inject(DestroyRef); + constructor( private service: TestService, private router: Router, @@ -95,7 +102,7 @@ export class TestComponent implements OnChanges { } onSubmit() { - if (!this.form.valid) { + if (!this.form.valid || this.submitting) { return; } @@ -108,10 +115,15 @@ export class TestComponent implements OnChanges { } this.problem = null; + this.submitting = true; this.service.submit( this.buildInputs(), this.files, this.form.controls.email.value || '', + ).pipe( + finalize(() => this.submitting = false), + // A response after the user has left must not pull them back to the job + takeUntilDestroyed(this.destroyRef), ).subscribe({ next: (job) => { this.submitted.emit(job.job_id); From 076e8fdf02d48f1158791292db48c9b3d9e8c98b Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:30:54 -0500 Subject: [PATCH 05/12] fix: Loading shows neither queued nor processing before the first status --- src/app/components/loading/loading.component.html | 2 +- src/app/components/loading/loading.component.spec.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/components/loading/loading.component.html b/src/app/components/loading/loading.component.html index 57dbac7..9a6a129 100644 --- a/src/app/components/loading/loading.component.html +++ b/src/app/components/loading/loading.component.html @@ -10,7 +10,7 @@
Queued: your job will start when a compute node is free.
- } @else { + } @else if (status === 'processing') {
Processing. Estimated processing time: {{ estimatedTimeString }}
diff --git a/src/app/components/loading/loading.component.spec.ts b/src/app/components/loading/loading.component.spec.ts index 200fca6..2c3c015 100644 --- a/src/app/components/loading/loading.component.spec.ts +++ b/src/app/components/loading/loading.component.spec.ts @@ -4,7 +4,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; import { provideEnvironmentServiceStub } from '~/app/testing/environment-service.stub'; -import { of } from 'rxjs'; +import { NEVER, of } from 'rxjs'; import { Job, JobStatus } from '~/app/models/v1-job'; import { LoadingComponent } from './loading.component'; @@ -36,6 +36,14 @@ describe('LoadingComponent', () => { expect(component).toBeTruthy(); }); + it('claims neither queued nor processing before the first status arrives', () => { + fixture.componentRef.setInput('status$', NEVER); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="job-processing"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('[data-testid="job-queued"]')).toBeNull(); + }); + it('says a queued job is waiting, not running', () => { show('queued'); expect(shown('job-queued')).not.toBeNull(); From 764b9066d467bfeac081174f8794146ad8f53356 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:30:55 -0500 Subject: [PATCH 06/12] ci: check the generated /v1 client against its snapshot; add provenance and links to Job FILES is the generator's own listing after a rerun, which is stable; the first generation also listed .openapi-generator-ignore. --- .github/workflows/e2e.yml | 3 +++ src/app/api/v1/.openapi-generator/FILES | 1 - src/app/models/v1-job.ts | 15 +++++++++++++++ src/app/services/test.examples.ts | 8 ++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4634690..e426f55 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,6 +32,9 @@ jobs: - name: Job types are up to date with schemas/ run: npm run schemas -- --check + - name: Generated /v1 client is up to date with its OpenAPI snapshot + run: npm run openapi:v1 && git diff --exit-code -- src/app/api/v1 + - name: Unit tests run: npx ng test --watch=false --browsers=ChromeHeadless diff --git a/src/app/api/v1/.openapi-generator/FILES b/src/app/api/v1/.openapi-generator/FILES index e4ec8b2..11f6aa1 100644 --- a/src/app/api/v1/.openapi-generator/FILES +++ b/src/app/api/v1/.openapi-generator/FILES @@ -1,5 +1,4 @@ .gitignore -.openapi-generator-ignore README.md api.base.service.ts api.module.ts diff --git a/src/app/models/v1-job.ts b/src/app/models/v1-job.ts index d784d61..bf2f845 100644 --- a/src/app/models/v1-job.ts +++ b/src/app/models/v1-job.ts @@ -21,6 +21,21 @@ export interface Job { finished_at: string | null; /** The input document as submitted */ inputs: unknown; + provenance: { + /** The configured image reference, which may be untagged or mutable */ + image: string | null; + /** What actually ran; null until the watcher reads it from the pod */ + image_digest: string | null; + command: string | null; + }; + links: { + self: string; + results: string; + artifacts: string; + tool: string; + cancel: string; + parent?: string; + }; } export interface JobResults { diff --git a/src/app/services/test.examples.ts b/src/app/services/test.examples.ts index f71eb47..69e11e8 100644 --- a/src/app/services/test.examples.ts +++ b/src/app/services/test.examples.ts @@ -18,6 +18,14 @@ export const EXAMPLE_JOB: Job = { { id: 'glucagon', sequence: 'HSQGTFTSDYSKYLDSRRAQDFVQWLMNT' }, ], }, + provenance: { image: null, image_digest: null, command: null }, + links: { + self: '/v1/jobs/example-id', + results: '/v1/jobs/example-id/results', + artifacts: '/v1/jobs/example-id/artifacts', + tool: '/v1/tools/job-template-sample', + cancel: '/v1/jobs/example-id/cancel', + }, }; export const EXAMPLE_RESULTS: ToolResults = { From 9cd5ea8d7c6dd64d3f2c26fa2a567f94de86b070 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:47:41 -0500 Subject: [PATCH 07/12] fix: a lasting outage ends the poll; gateway 502s on results are retried - The transient retry sat inside switchMap on the 10 s tick, so every tick reset its count and a lasting outage never reached the error. Consecutive transient failures are now counted across ticks (3, about 30 s). - Transient means 0/502/503/504 WITHOUT a problem document. The backend's own 502 (invalid results.json, upstream-failure) carries one and is final; a gateway's doesn't. getResults now retries the latter twice. - The first polling test no longer calls discardPeriodicTasks(). It did nothing: fakeAsync in zone.js 0.15 defaults to flush: true, which flushes leftover periodic timers instead of failing, so the call count is the leak check. --- .../test-result/test-result.errors.spec.ts | 32 ++++++++++++++++++- .../test-result/test-result.polling.spec.ts | 6 ++-- src/app/models/job-result.ts | 24 +++++++++++--- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/app/components/test-result/test-result.errors.spec.ts b/src/app/components/test-result/test-result.errors.spec.ts index 1cc26cc..f264aa3 100644 --- a/src/app/components/test-result/test-result.errors.spec.ts +++ b/src/app/components/test-result/test-result.errors.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { provideHttpClient, HttpErrorResponse } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; +import { defer, of, throwError } from 'rxjs'; import { TestService } from '~/app/services/test.service'; import { provideEnvironmentServiceStub } from '~/app/testing/environment-service.stub'; @@ -63,6 +63,36 @@ describe('TestResultComponent when a request fails', () => { fixture.destroy(); })); + it('gives up and shows an error when the outage lasts', fakeAsync(() => { + service.getJob.and.returnValue(throwError(() => httpError(502))); + create(); + tick(0); + for (let i = 0; i < 5; i++) tick(10000); + fixture.detectChanges(); + + expect(shown('load-error')).not.toBeNull(); + expect(shown('job-processing')).toBeNull(); + fixture.destroy(); + })); + + it('retries a gateway 502 for the results, then shows them', fakeAsync(() => { + service.getJob.and.returnValue(of({ job_id: 'j1', tool: 'job-template-sample', status: 'completed' } as any)); + // Cold, like HttpClient: each subscription is a new request + let requests = 0; + service.getResults.and.returnValue(defer(() => ++requests === 1 + ? throwError(() => httpError(502)) // no problem document: the gateway, not the API + : of({ rows: [], artifacts: [] } as any))); + create(); + tick(0); + tick(10000); + tick(1000); + fixture.detectChanges(); + + expect(shown('load-error')).toBeNull(); + expect(requests).toBe(2); + fixture.destroy(); + })); + it("says so instead of rendering another tool's job, whose inputs aren't this tool's", fakeAsync(() => { service.getJob.and.returnValue(of({ job_id: 'j1', tool: 'somn', status: 'processing', inputs: {} } as any)); create(); diff --git a/src/app/components/test-result/test-result.polling.spec.ts b/src/app/components/test-result/test-result.polling.spec.ts index f97049a..7c4f5f4 100644 --- a/src/app/components/test-result/test-result.polling.spec.ts +++ b/src/app/components/test-result/test-result.polling.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; @@ -35,7 +35,6 @@ describe('TestResultComponent status polling', () => { expect(service.getJob).toHaveBeenCalledTimes(2); fixture.destroy(); - discardPeriodicTasks(); })); it('stops polling once the page is destroyed', fakeAsync(() => { @@ -55,7 +54,8 @@ describe('TestResultComponent status polling', () => { fixture.destroy(); tick(30000); - // No discardPeriodicTasks() here: a timer still running would also fail the test. + // The call count is the leak check. fakeAsync (zone.js 0.15) defaults to + // flush: true, which flushes leftover periodic timers instead of failing on them. expect(service.getJob).toHaveBeenCalledTimes(callsBeforeDestroy); })); }); diff --git a/src/app/models/job-result.ts b/src/app/models/job-result.ts index 0f3d99b..de99ccd 100644 --- a/src/app/models/job-result.ts +++ b/src/app/models/job-result.ts @@ -4,11 +4,18 @@ import { TestService } from '~/app/services/test.service'; import { APP_IDENTITY } from '~/app/app-identity'; import { Problem, TERMINAL_STATUSES } from '~/app/models/v1-job'; -/** A network failure or a gateway error during a deploy: worth asking again. */ +/** + * A network failure, or a gateway error during a deploy: worth asking again. The + * backend's own errors are problem documents, and those are final, including its + * 502 "Job produced invalid results"; a gateway's 502 carries no problem document. + */ function isTransient(error: HttpErrorResponse): boolean { - return error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504; + return [0, 502, 503, 504].includes(error.status) && !error.error?.type; } +/** Consecutive polls that may fail transiently before the page gives up (30 s at one per 10 s). */ +const TRANSIENT_POLLS = 3; + export class JobResult { jobId: string; @@ -38,10 +45,15 @@ export class JobResult { * still closed, and jobResultResponse$ completed without ever fetching. * refCount lets the poll stop when nothing is listening. */ + private transientFailures = 0; + statusResponse$ = timer(0, 10000).pipe( - // Ask again a couple of times on a transient error; anything else ends the poll + // A transient error skips this poll and the next tick asks again. The count runs + // across ticks, so a lasting outage still ends the poll; anything else ends it now. switchMap(() => this.service.getJob(this.jobId).pipe( - retry({ count: 2, delay: (error) => isTransient(error) ? timer(5000) : throwError(() => error) }), + tap(() => this.transientFailures = 0), + catchError((error: HttpErrorResponse) => + isTransient(error) && ++this.transientFailures < TRANSIENT_POLLS ? EMPTY : throwError(() => error)), )), // /v1 job ids are global, so /result/ can name another tool's job, whose // inputs and results this page can't render @@ -67,7 +79,9 @@ export class JobResult { jobResultResponse$ = this.statusResponse$.pipe( filter((job) => job.status === 'completed'), take(1), - switchMap(() => this.service.getResults(this.jobId)), + switchMap(() => this.service.getResults(this.jobId).pipe( + retry({ count: 2, delay: (error) => isTransient(error) ? timer(5000) : throwError(() => error) }), + )), catchError(this.fail), delay(1000), tap(() => this.isLoading$.next(false)), From c220cd7e5543be23597182c21ffc2a9a9fa91f34 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:47:42 -0500 Subject: [PATCH 08/12] fix: let a submit finish after the form is destroyed; hold the guard through navigation takeUntilDestroyed cancelled an upload the backend may already have accepted, hiding the job it created. The request now completes, and only the navigation is skipped once the form is gone. Submit stays disabled until the navigation to the new job resolves, not just until the POST does. --- .../components/test/test.component.spec.ts | 21 +++++++++++++++++-- src/app/components/test/test.component.ts | 20 ++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/app/components/test/test.component.spec.ts b/src/app/components/test/test.component.spec.ts index fcb7141..4d8f515 100644 --- a/src/app/components/test/test.component.spec.ts +++ b/src/app/components/test/test.component.spec.ts @@ -79,13 +79,30 @@ describe('TestComponent', () => { expect(submit).toHaveBeenCalledTimes(2); }); - it('does not navigate to the job if the user has left the page', () => { + it('lets the request finish if the user leaves, but does not navigate to the job', () => { pressSubmit(); fixture.destroy(); - response.next({ job_id: 'job-b' } as Job); + // Cancelling would abort an upload the backend may already have accepted + expect(response.observed).toBeTrue(); + response.next({ job_id: 'job-b' } as Job); expect(navigate).not.toHaveBeenCalled(); }); + + it('stays disabled until the navigation to the new job has finished', async () => { + let finishNavigation!: (ok: boolean) => void; + navigate.and.returnValue(new Promise((resolve) => finishNavigation = resolve)); + pressSubmit(); + response.next({ job_id: 'job-b' } as Job); + response.complete(); + + pressSubmit(); + expect(submit).toHaveBeenCalledTimes(1); + + finishNavigation(true); + await fixture.whenStable(); + expect(component.submitting).toBeFalse(); + }); }); it('emits the created job id once the submission resolves, then navigates to it', () => { diff --git a/src/app/components/test/test.component.ts b/src/app/components/test/test.component.ts index c067067..69ac1d2 100644 --- a/src/app/components/test/test.component.ts +++ b/src/app/components/test/test.component.ts @@ -1,6 +1,4 @@ import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { finalize } from "rxjs"; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from "@angular/forms"; import { HttpErrorResponse } from "@angular/common/http"; import { Router } from "@angular/router"; @@ -65,12 +63,14 @@ export class TestComponent implements OnChanges { /** True from submit until the backend answers; a second submit meanwhile is ignored. */ submitting = false; - private destroyRef = inject(DestroyRef); + private destroyed = false; constructor( private service: TestService, private router: Router, - ) { } + ) { + inject(DestroyRef).onDestroy(() => this.destroyed = true); + } ngOnChanges(changes: SimpleChanges): void { if (changes['formValue'] && changes['formValue'].currentValue) { @@ -120,17 +120,19 @@ export class TestComponent implements OnChanges { this.buildInputs(), this.files, this.form.controls.email.value || '', - ).pipe( - finalize(() => this.submitting = false), - // A response after the user has left must not pull them back to the job - takeUntilDestroyed(this.destroyRef), ).subscribe({ + // Not cancelled when the page goes away: the backend may already have accepted + // the upload, and cancelling would only hide the job it created. A response + // after the user has left must not pull them back to it, though. next: (job) => { + if (this.destroyed) return; this.submitted.emit(job.job_id); - this.router.navigate(['result', job.job_id]); + // Enabled again only once the navigation is over, or a click meanwhile resubmits + this.router.navigate(['result', job.job_id]).finally(() => this.submitting = false); }, // /v1 errors are problem documents; anything else (a network failure) isn't error: (error: HttpErrorResponse) => { + this.submitting = false; this.problem = error.error?.title ? error.error : { type: 'about:blank', title: 'The job could not be submitted', status: error.status }; From 77d05e6be5e52e160334e3a2ecf25441df36c8b6 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 22:47:42 -0500 Subject: [PATCH 09/12] fix: Loading shows error/canceled at once; example job uses app.yaml's job_type The error and canceled screens were set as a side effect of the 1 s progress interval and read with .value, so they appeared a second late. They now follow the status directly. EXAMPLE_JOB.tool comes from APP_IDENTITY, so frontend-only mode isn't rejected as another tool's job once job_type changes. --- src/app/components/loading/loading.component.html | 8 ++++---- src/app/components/loading/loading.component.spec.ts | 9 +++++++++ src/app/components/loading/loading.component.ts | 12 ++---------- src/app/services/test.examples.ts | 6 ++++-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/app/components/loading/loading.component.html b/src/app/components/loading/loading.component.html index 9a6a129..a0c8eec 100644 --- a/src/app/components/loading/loading.component.html +++ b/src/app/components/loading/loading.component.html @@ -1,5 +1,5 @@ @let status = (status$ | async)?.status; -
+
Error
@@ -42,7 +42,7 @@
Error
Canceled
diff --git a/src/app/components/loading/loading.component.spec.ts b/src/app/components/loading/loading.component.spec.ts index 2c3c015..dc8937b 100644 --- a/src/app/components/loading/loading.component.spec.ts +++ b/src/app/components/loading/loading.component.spec.ts @@ -63,6 +63,15 @@ describe('LoadingComponent', () => { fixture.detectChanges(); } + it('shows the error and canceled states as soon as the status arrives', () => { + show('error'); + expect(shown('job-error')).not.toBeNull(); + + show('canceled'); + expect(shown('job-canceled')).not.toBeNull(); + expect(shown('job-error')).toBeNull(); + }); + it('shows the error state for a failed job', fakeAsync(() => { showAfterATick('error'); expect(shown('job-error')).not.toBeNull(); diff --git a/src/app/components/loading/loading.component.ts b/src/app/components/loading/loading.component.ts index 74d7e21..69d4058 100644 --- a/src/app/components/loading/loading.component.ts +++ b/src/app/components/loading/loading.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { BehaviorSubject, combineLatest, interval, map, Observable } from "rxjs"; +import { combineLatest, interval, map, Observable } from "rxjs"; import { NgIf, AsyncPipe } from "@angular/common"; import { ProgressBarModule } from "primeng/progressbar"; @@ -26,8 +26,6 @@ export class LoadingComponent implements OnInit { estimatedTime: number; estimatedTimeString: string; - showError$ = new BehaviorSubject(false); - showCanceled$ = new BehaviorSubject(false); form = new FormGroup({ agreeToSubscription: new FormControl(false), @@ -69,13 +67,7 @@ export class LoadingComponent implements OnInit { case 'completed': return 100; - case 'error': - this.showError$.next(true); - return 0; - - case 'canceled': - this.showCanceled$.next(true); - return 0; + // 'error' and 'canceled' have no progress; the template shows their own screens } return 0; } diff --git a/src/app/services/test.examples.ts b/src/app/services/test.examples.ts index 69e11e8..79843b8 100644 --- a/src/app/services/test.examples.ts +++ b/src/app/services/test.examples.ts @@ -1,12 +1,14 @@ import { ToolResults } from "@api/tool-schemas"; import { Job } from "~/app/models/v1-job"; +import { APP_IDENTITY } from "~/app/app-identity"; // What frontendOnly mode shows instead of calling the backend. TODO: replace with an // example job and results for your tool. These are the sample tool's, from // moleculemaker/job-template examples/. export const EXAMPLE_JOB: Job = { job_id: 'example-id', - tool: 'job-template-sample', + // Must match app.yaml's job_type, or the result page rejects it as another tool's job + tool: APP_IDENTITY.jobType, status: 'completed', parent_job_id: null, submitted_at: '2026-10-20T12:00:00Z', @@ -23,7 +25,7 @@ export const EXAMPLE_JOB: Job = { self: '/v1/jobs/example-id', results: '/v1/jobs/example-id/results', artifacts: '/v1/jobs/example-id/artifacts', - tool: '/v1/tools/job-template-sample', + tool: `/v1/tools/${APP_IDENTITY.jobType}`, cancel: '/v1/jobs/example-id/cancel', }, }; From 4710035db40cdf05c450989d46e2d4e6790637b8 Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 23:24:49 -0500 Subject: [PATCH 10/12] fix: retry network failures and hung polls; restart the failure count with the poll - isTransient treated status 0 as final: Angular 20.3's XHR backend puts the ProgressEvent in error.error, and its .type is 'error'. A problem document is now an object with a string title, as fail() already assumed. - switchMap cancelled a request slower than the 10 s tick before its failure was counted, so a hung request kept the page on Processing. exhaustMap lets it finish, and timeout(10 s) bounds one that never answers. - The count is reset when refCount restarts the poll (defer), not only on success. - The give-up doc now matches measured behavior: 20 s after the first failure when polls fail fast, 30 s when each times out. --- src/app/models/job-result.spec.ts | 51 +++++++++++++++++++++++++++- src/app/models/job-result.ts | 55 ++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/app/models/job-result.spec.ts b/src/app/models/job-result.spec.ts index c960a6f..2e5f164 100644 --- a/src/app/models/job-result.spec.ts +++ b/src/app/models/job-result.spec.ts @@ -1,5 +1,6 @@ import { fakeAsync, tick } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { NEVER, Observable, of, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { JobResult } from './job-result'; import { JobStatus } from '~/app/models/v1-job'; @@ -96,3 +97,51 @@ describe('JobResult polling lifecycle', () => { expect(service.getResults).toHaveBeenCalledTimes(1); })); }); + +describe('JobResult when the backend cannot be reached', () => { + const job = { job_id: 'job-1', tool: 'job-template-sample', status: 'processing' }; + + /** What Angular's XHR backend reports for a network failure: status 0 and the XHR ProgressEvent. */ + const networkError = () => new HttpErrorResponse({ error: new ProgressEvent('error'), status: 0 }); + + function make(getJob: () => Observable) { + const service: any = { getJob: jasmine.createSpy('getJob').and.callFake(getJob), getResults: jasmine.createSpy('getResults') }; + const jr = new JobResult(service); + jr.jobId = 'job-1'; + return jr; + } + + it('retries a network failure (status 0) instead of giving up at once', fakeAsync(() => { + let calls = 0; + const jr = make(() => ++calls === 1 ? throwError(networkError) : of(job)); + const sub = jr.statusResponse$.subscribe(); + tick(0); + tick(10000); + + expect(jr.loadProblem$.value).toBeNull(); + sub.unsubscribe(); + })); + + it('gives up on a request that never answers', fakeAsync(() => { + const jr = make(() => NEVER); + const sub = jr.statusResponse$.subscribe(); + for (let i = 0; i < 6; i++) tick(10000); + + expect(jr.loadProblem$.value).not.toBeNull(); + sub.unsubscribe(); + })); + + it('starts counting failures afresh when the poll restarts', fakeAsync(() => { + let failing = true; + const jr = make(() => failing ? throwError(networkError) : of(job)); + let sub = jr.statusResponse$.subscribe(); + tick(0); + tick(10000); // two failures, one short of giving up + sub.unsubscribe(); // e.g. the user opens the Input tab + + sub = jr.statusResponse$.subscribe(); + tick(0); // one more failure after the restart + expect(jr.loadProblem$.value).toBeNull(); + sub.unsubscribe(); + })); +}); diff --git a/src/app/models/job-result.ts b/src/app/models/job-result.ts index de99ccd..58bb957 100644 --- a/src/app/models/job-result.ts +++ b/src/app/models/job-result.ts @@ -1,19 +1,33 @@ -import { timer, take, switchMap, tap, takeWhile, BehaviorSubject, filter, delay, shareReplay, catchError, retry, throwError, map, EMPTY, Observable } from "rxjs"; +import { timer, take, switchMap, exhaustMap, tap, takeWhile, BehaviorSubject, filter, delay, shareReplay, catchError, retry, throwError, map, timeout, defer, EMPTY, Observable, TimeoutError } from "rxjs"; import { HttpErrorResponse } from "@angular/common/http"; import { TestService } from '~/app/services/test.service'; import { APP_IDENTITY } from '~/app/app-identity'; import { Problem, TERMINAL_STATUSES } from '~/app/models/v1-job'; +/** An RFC 9457 problem document from the backend, rather than a gateway page or a network error. */ +function isProblemDocument(body: unknown): boolean { + // Not just `.type`: for a network failure Angular's XHR backend puts the XHR + // ProgressEvent here, and its `type` is 'error' + return !!body && typeof body === 'object' && typeof (body as Problem).title === 'string'; +} + /** - * A network failure, or a gateway error during a deploy: worth asking again. The - * backend's own errors are problem documents, and those are final, including its - * 502 "Job produced invalid results"; a gateway's 502 carries no problem document. + * A network failure, a request that took longer than a poll, or a gateway error during + * a deploy: worth asking again. The backend's own errors are problem documents, and + * those are final, including its 502 "Job produced invalid results". */ -function isTransient(error: HttpErrorResponse): boolean { - return [0, 502, 503, 504].includes(error.status) && !error.error?.type; +function isTransient(error: unknown): boolean { + if (error instanceof TimeoutError) return true; + return error instanceof HttpErrorResponse + && [0, 502, 503, 504].includes(error.status) && !isProblemDocument(error.error); } -/** Consecutive polls that may fail transiently before the page gives up (30 s at one per 10 s). */ +const POLL_MS = 10000; + +/** + * The page gives up on the third poll in a row that fails transiently: 20 s after the + * first failure when they fail fast, 30 s when each runs into the POLL_MS timeout. + */ const TRANSIENT_POLLS = 3; export class JobResult { @@ -26,14 +40,19 @@ export class JobResult { loadProblem$ = new BehaviorSubject(null); /** Stop polling and show why. /v1 errors are problem documents; anything else isn't. */ - private fail = (error: HttpErrorResponse): Observable => { - this.loadProblem$.next(error.error?.title + private fail = (error: unknown): Observable => { + this.loadProblem$.next(error instanceof HttpErrorResponse && isProblemDocument(error.error) ? error.error - : { type: 'about:blank', title: 'The job could not be loaded', status: error.status }); + // a gateway page, a network failure, or the poll timeout + : { type: 'about:blank', title: 'The job could not be loaded', + status: error instanceof HttpErrorResponse ? error.status : 0 }); this.isLoading$.next(false); return EMPTY; }; + /** Polls in a row that failed transiently; reset when the poll (re)starts or succeeds. */ + private transientFailures = 0; + /** * One shared poll: every consumer (template `| async`, jobResultResponse$, and * , which receives this stream as its status$ input) rides this @@ -45,14 +64,20 @@ export class JobResult { * still closed, and jobResultResponse$ completed without ever fetching. * refCount lets the poll stop when nothing is listening. */ - private transientFailures = 0; - - statusResponse$ = timer(0, 10000).pipe( + statusResponse$ = defer(() => { + // refCount restarts the poll after every consumer has left, e.g. while the + // Input tab is open; a fresh poll starts its count afresh + this.transientFailures = 0; + return timer(0, POLL_MS); + }).pipe( // A transient error skips this poll and the next tick asks again. The count runs // across ticks, so a lasting outage still ends the poll; anything else ends it now. - switchMap(() => this.service.getJob(this.jobId).pipe( + // exhaustMap, not switchMap, so a slow request is not cancelled by the next tick + // before its failure is counted; timeout bounds one that never answers. + exhaustMap(() => this.service.getJob(this.jobId).pipe( + timeout(POLL_MS), tap(() => this.transientFailures = 0), - catchError((error: HttpErrorResponse) => + catchError((error: unknown) => isTransient(error) && ++this.transientFailures < TRANSIENT_POLLS ? EMPTY : throwError(() => error)), )), // /v1 job ids are global, so /result/ can name another tool's job, whose From 888549d9be34256d9bb81c3e86109974093b686a Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 23:24:50 -0500 Subject: [PATCH 11/12] chore: submitting doc matches its reset; use DestroyRef.destroyed; drop stale Loading tests The tick-based error and canceled tests and their helper described the mechanism the previous commit removed, and duplicated the immediate test. --- .../loading/loading.component.spec.ts | 21 +------------------ src/app/components/test/test.component.ts | 14 +++++++------ 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/app/components/loading/loading.component.spec.ts b/src/app/components/loading/loading.component.spec.ts index dc8937b..406fc5b 100644 --- a/src/app/components/loading/loading.component.spec.ts +++ b/src/app/components/loading/loading.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; @@ -56,13 +56,6 @@ describe('LoadingComponent', () => { expect(shown('job-queued')).toBeNull(); }); - // The terminal states are set by the progress stream, which ticks once a second - function showAfterATick(status: JobStatus) { - show(status); - tick(1000); - fixture.detectChanges(); - } - it('shows the error and canceled states as soon as the status arrives', () => { show('error'); expect(shown('job-error')).not.toBeNull(); @@ -72,16 +65,4 @@ describe('LoadingComponent', () => { expect(shown('job-error')).toBeNull(); }); - it('shows the error state for a failed job', fakeAsync(() => { - showAfterATick('error'); - expect(shown('job-error')).not.toBeNull(); - discardPeriodicTasks(); - })); - - it('shows the canceled state for a canceled job, not the error', fakeAsync(() => { - showAfterATick('canceled'); - expect(shown('job-canceled')).not.toBeNull(); - expect(shown('job-error')).toBeNull(); - discardPeriodicTasks(); - })); }); diff --git a/src/app/components/test/test.component.ts b/src/app/components/test/test.component.ts index 69ac1d2..33451b8 100644 --- a/src/app/components/test/test.component.ts +++ b/src/app/components/test/test.component.ts @@ -60,17 +60,19 @@ export class TestComponent implements OnChanges { currentPage = 'input'; - /** True from submit until the backend answers; a second submit meanwhile is ignored. */ + /** + * True from submit until the navigation to the new job settles, or the submit fails. + * A second submit meanwhile is ignored. Clearing it when the response arrives instead + * would let a click during the navigation create a second job. + */ submitting = false; - private destroyed = false; + private destroyRef = inject(DestroyRef); constructor( private service: TestService, private router: Router, - ) { - inject(DestroyRef).onDestroy(() => this.destroyed = true); - } + ) { } ngOnChanges(changes: SimpleChanges): void { if (changes['formValue'] && changes['formValue'].currentValue) { @@ -125,7 +127,7 @@ export class TestComponent implements OnChanges { // the upload, and cancelling would only hide the job it created. A response // after the user has left must not pull them back to it, though. next: (job) => { - if (this.destroyed) return; + if (this.destroyRef.destroyed) return; this.submitted.emit(job.job_id); // Enabled again only once the navigation is over, or a click meanwhile resubmits this.router.navigate(['result', job.job_id]).finally(() => this.submitting = false); From 18d568d32864bdc9e2487b703878d9e1b1250ded Mon Sep 17 00:00:00 2001 From: matthewberry Date: Tue, 27 Oct 2026 23:33:57 -0500 Subject: [PATCH 12/12] fix: poll requests time out before the next tick; bound the results download - The request timeout equalled POLL_MS, so in a browser the next tick could arrive while the request was still open and exhaustMap dropped it, pushing the give-up past the documented time. Requests now time out after 8 s, and the test pins the give-up at exactly 28 s. - getResults gets its own 2-minute timeout, so a stalled download ends. - isProblemDocument moves to models/v1-job.ts, and the submit form uses it too. --- src/app/components/test/test.component.ts | 4 ++-- src/app/models/job-result.spec.ts | 9 ++++++--- src/app/models/job-result.ts | 21 +++++++++++---------- src/app/models/v1-job.ts | 7 +++++++ 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/app/components/test/test.component.ts b/src/app/components/test/test.component.ts index 33451b8..e51180c 100644 --- a/src/app/components/test/test.component.ts +++ b/src/app/components/test/test.component.ts @@ -7,7 +7,7 @@ import { ButtonModule } from "primeng/button"; import { CommonModule } from "@angular/common"; import { ToolInput } from "@api/tool-schemas"; -import { Problem } from "~/app/models/v1-job"; +import { Problem, isProblemDocument } from "~/app/models/v1-job"; import { JobTabComponent } from "~/app/components/job-tab/job-tab.component"; import { TestService } from '~/app/services/test.service'; @@ -135,7 +135,7 @@ export class TestComponent implements OnChanges { // /v1 errors are problem documents; anything else (a network failure) isn't error: (error: HttpErrorResponse) => { this.submitting = false; - this.problem = error.error?.title + this.problem = isProblemDocument(error.error) ? error.error : { type: 'about:blank', title: 'The job could not be submitted', status: error.status }; }, diff --git a/src/app/models/job-result.spec.ts b/src/app/models/job-result.spec.ts index 2e5f164..878acce 100644 --- a/src/app/models/job-result.spec.ts +++ b/src/app/models/job-result.spec.ts @@ -122,11 +122,14 @@ describe('JobResult when the backend cannot be reached', () => { sub.unsubscribe(); })); - it('gives up on a request that never answers', fakeAsync(() => { + it('gives up 28 s in when no request answers', fakeAsync(() => { + // Polls at 0, 10 and 20 s each time out after 8 s, shorter than the 10 s poll, so + // no tick is dropped while a request is still open const jr = make(() => NEVER); const sub = jr.statusResponse$.subscribe(); - for (let i = 0; i < 6; i++) tick(10000); - + tick(27999); + expect(jr.loadProblem$.value).toBeNull(); + tick(1); expect(jr.loadProblem$.value).not.toBeNull(); sub.unsubscribe(); })); diff --git a/src/app/models/job-result.ts b/src/app/models/job-result.ts index 58bb957..1b5ec14 100644 --- a/src/app/models/job-result.ts +++ b/src/app/models/job-result.ts @@ -2,14 +2,7 @@ import { timer, take, switchMap, exhaustMap, tap, takeWhile, BehaviorSubject, fi import { HttpErrorResponse } from "@angular/common/http"; import { TestService } from '~/app/services/test.service'; import { APP_IDENTITY } from '~/app/app-identity'; -import { Problem, TERMINAL_STATUSES } from '~/app/models/v1-job'; - -/** An RFC 9457 problem document from the backend, rather than a gateway page or a network error. */ -function isProblemDocument(body: unknown): boolean { - // Not just `.type`: for a network failure Angular's XHR backend puts the XHR - // ProgressEvent here, and its `type` is 'error' - return !!body && typeof body === 'object' && typeof (body as Problem).title === 'string'; -} +import { Problem, TERMINAL_STATUSES, isProblemDocument } from '~/app/models/v1-job'; /** * A network failure, a request that took longer than a poll, or a gateway error during @@ -24,9 +17,16 @@ function isTransient(error: unknown): boolean { const POLL_MS = 10000; +/** Clearly shorter than POLL_MS: with exhaustMap, a tick that arrives while a request is open is dropped. */ +const REQUEST_TIMEOUT_MS = 8000; + +/** A results download can be large, but a stalled one must still end. */ +const RESULTS_TIMEOUT_MS = 2 * 60 * 1000; + /** * The page gives up on the third poll in a row that fails transiently: 20 s after the - * first failure when they fail fast, 30 s when each runs into the POLL_MS timeout. + * first failure when they fail fast, 28 s in when no request answers (the polls at 0, + * 10 and 20 s each time out after REQUEST_TIMEOUT_MS). */ const TRANSIENT_POLLS = 3; @@ -75,7 +75,7 @@ export class JobResult { // exhaustMap, not switchMap, so a slow request is not cancelled by the next tick // before its failure is counted; timeout bounds one that never answers. exhaustMap(() => this.service.getJob(this.jobId).pipe( - timeout(POLL_MS), + timeout(REQUEST_TIMEOUT_MS), tap(() => this.transientFailures = 0), catchError((error: unknown) => isTransient(error) && ++this.transientFailures < TRANSIENT_POLLS ? EMPTY : throwError(() => error)), @@ -105,6 +105,7 @@ export class JobResult { filter((job) => job.status === 'completed'), take(1), switchMap(() => this.service.getResults(this.jobId).pipe( + timeout(RESULTS_TIMEOUT_MS), retry({ count: 2, delay: (error) => isTransient(error) ? timer(5000) : throwError(() => error) }), )), catchError(this.fail), diff --git a/src/app/models/v1-job.ts b/src/app/models/v1-job.ts index bf2f845..50f0abb 100644 --- a/src/app/models/v1-job.ts +++ b/src/app/models/v1-job.ts @@ -53,3 +53,10 @@ export interface Problem { /** For invalid input: a JSON Pointer into the submitted document, and what is wrong there */ errors?: { pointer: string; detail: string }[]; } + +/** An RFC 9457 problem document from the backend, rather than a gateway page or a network error. */ +export function isProblemDocument(body: unknown): body is Problem { + // Not just `.type`: for a network failure Angular's XHR backend puts the XHR + // ProgressEvent here, and its `type` is 'error' + return !!body && typeof body === 'object' && typeof (body as Problem).title === 'string'; +}