diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
index a9d4d3550e..e6e4f08230 100644
--- a/.github/config/.files.yaml
+++ b/.github/config/.files.yaml
@@ -123,8 +123,10 @@ generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
+ - frontend/editor/src/core/types/toolIO.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
+ - engine/src/stirling/models/tool_io.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml
index bf726bec2e..f108a55afc 100644
--- a/.github/workflows/check-generated-models.yml
+++ b/.github/workflows/check-generated-models.yml
@@ -1,12 +1,12 @@
name: Check generated models
-# Verifies the committed generated API models are still in sync with the Java
-# OpenAPI spec: the frontend tool API types
-# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
-# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
-# single top-level `task tool-models` and fails if either committed file is
-# out of date. Called from build.yml when the backend Java, frontend, or engine
-# changes; also runs on push to main as a post-merge safety net.
+# Verifies the committed generated files are still in sync with the Java OpenAPI
+# spec: the request models (toolApiTypes.ts, tool_models.py) and the tool I/O
+# tables saying what each endpoint accepts and produces (toolIO.ts, tool_io.py).
+# Regenerates them all with the single top-level `task tool-models` and fails if
+# any committed file is out of date. Called from build.yml when the
+# backend Java, frontend, or engine changes; also runs on push to main as a
+# post-merge safety net.
on:
workflow_call:
push:
@@ -57,18 +57,10 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- # Rebuilds the OpenAPI spec from the current Java and regenerates both the
- # frontend types and the engine tool models from it.
- - name: Regenerate generated models
- run: task tool-models
-
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
- run: |
- git diff --exit-code \
- frontend/editor/src/core/types/toolApiTypes.ts \
- engine/src/stirling/models/tool_models.py
+ run: task tool-models:check
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
@@ -83,9 +75,9 @@ jobs:
marker,
'### Generated Models Check Failed',
'',
- 'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
+ 'One or more generated files are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
- 'Run `task tool-models` to regenerate both, then commit the updated files.',
+ 'Run `task tool-models` to regenerate them, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -116,11 +108,10 @@ jobs:
echo " Generated Models Check Failed"
echo "============================================"
echo ""
- echo "The generated frontend API types and/or engine tool"
- echo "models are out of date with the Java OpenAPI spec and"
- echo "will need to be regenerated before they can be merged in."
+ echo "One or more generated files are out of date with the Java"
+ echo "OpenAPI spec and will need to be regenerated before merging."
echo ""
- echo "Run 'task tool-models' to regenerate both, then"
+ echo "Run 'task tool-models' to regenerate them, then"
echo "commit the updated files."
echo "============================================"
exit 1
diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml
index cfe0790241..b62aeb5308 100644
--- a/.taskfiles/engine.yml
+++ b/.taskfiles/engine.yml
@@ -102,12 +102,19 @@ tasks:
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- - uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py
+ - uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
generates:
- src/stirling/models/tool_models.py
+ - src/stirling/models/tool_io.py
+
+ tool-models:check:
+ desc: "Fail if the committed tool models are out of date"
+ deps: [install, ":backend:swagger"]
+ cmds:
+ - uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
clean:
desc: "Clean build artifacts"
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index e737f897f2..d3ce0d86dd 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -498,18 +498,19 @@ tasks:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
+ - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
+ - editor/src/core/types/toolIO.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
+ - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
licenses:generate:
desc: "Generate frontend license report"
diff --git a/DeveloperGuide.md b/DeveloperGuide.md
index fbbf6478ac..286bf47fa5 100644
--- a/DeveloperGuide.md
+++ b/DeveloperGuide.md
@@ -504,7 +504,8 @@ For Stirling 2.0, new features are built as React components:
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- - Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
+ - Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates.")`.
+ - If the endpoint transforms a document, declare what it accepts and produces with `@ToolIO`, for example `@ToolIO(produces = ToolFormat.PDF)`. This is what lets a pipeline containing the step be checked before it runs, so a chain that cannot work is caught in the builder rather than part-way through a job. Endpoints under the tool namespaces are required to carry it - `ToolIODeclarationCoverageTest` fails the build otherwise. See [Declaring tool inputs and outputs](#declaring-tool-inputs-and-outputs).
```java
package stirling.software.SPDF.controller.api;
@@ -578,6 +579,38 @@ For Stirling 2.0, new features are built as React components:
}
```
+### Declaring tool inputs and outputs
+
+An endpoint that transforms a document declares what it accepts and produces with `@ToolIO`. This is the single source of truth: it is published into the OpenAPI spec as an `x-stirling-io` extension, and generated from there into the frontend (`toolIO.ts`) and the AI engine (`tool_io.py`). A pipeline can therefore be checked while it is being edited, instead of failing part-way through a job.
+
+```java
+@ToolIO(produces = ToolFormat.PDF)
+```
+
+`accepts` defaults to `{ ToolFormat.PDF }` and `arity` to `ToolArity.SISO`, so most tools only declare what they produce.
+
+- **`ToolFormat`** is the kind of file: `PDF`, `PDF_ENCRYPTED`, `IMAGE`, `ZIP`, `WORD`, `PPT`, `EXCEL`, `CSV`, `HTML`, `XML`, `JSON`, `TEXT`, `MARKDOWN`, `JAVASCRIPT`, `EBOOK`, `EMAIL`, `POSTSCRIPT`, `VIDEO`, `CBZ`, `CBR`, plus `ANY` (accepts or produces anything) and `NONE` (returns a report, not a file). Encryption is a format rather than a flag, so the default `accepts = PDF` means an endpoint rejects an encrypted PDF unless it opts in.
+- **`ToolArity`** is how many files go in and out: `SISO`, `SIMO`, `MISO`, `MIMO`. This axis carries ZIP-as-transport. A splitter is `produces = PDF, arity = SIMO`, and the caller unpacks the archive; an endpoint whose deliverable really is an archive declares `produces = ZIP` with a single-output arity and stays packed.
+
+When the output depends on a parameter, declare the exception as a case rather than picking one answer. Add Password produces an encrypted PDF unless both passwords are blank, in which case it has only set permissions:
+
+```java
+@ToolIO(
+ produces = ToolFormat.PDF_ENCRYPTED,
+ cases =
+ @ToolIOCase(
+ when = {
+ @ToolIOWhen(param = "password", matches = ""),
+ @ToolIOWhen(param = "ownerPassword", matches = "")
+ },
+ produces = ToolFormat.PDF,
+ arity = ToolArity.SISO))
+```
+
+Every condition in a `when` must hold for the case to apply, and `matches` is compared as a string, case-insensitively, with an empty string matching an absent or blank value. If a case reads a parameter that is not set yet, the output is reported as uncertain and the chain warns rather than erroring.
+
+Endpoints under the tool namespaces must carry a declaration; `ToolIODeclarationCoverageTest` fails the build for any that does not, with a short allowlist for endpoints that manage a session, a device or a stored resource rather than transforming a document. The matching rules are implemented three times (Java `ToolChainValidator`, `toolIOCompat.ts`, `tool_io_compat.py`) and pinned to the same answers by the shared fixtures in `testing/tool-io-cases.json`, so a behaviour change belongs in that file first.
+
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
diff --git a/Taskfile.yml b/Taskfile.yml
index 75183bd4f9..304a031ac0 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -194,6 +194,12 @@ tasks:
- task: frontend:tool-models
- task: engine:tool-models
+ tool-models:check:
+ desc: "Fail if any committed API model is out of date"
+ cmds:
+ - task: frontend:tool-models:check
+ - task: engine:tool-models:check
+
# ============================================================
# Quality Gate
# ============================================================
diff --git a/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java b/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java
new file mode 100644
index 0000000000..c7ea1b4f7c
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java
@@ -0,0 +1,105 @@
+package stirling.software.common.config.swagger;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
+import org.springdoc.core.customizers.GlobalOperationCustomizer;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+
+import stirling.software.common.model.tool.ToolArity;
+import stirling.software.common.model.tool.ToolFormat;
+import stirling.software.common.model.tool.ToolIO;
+import stirling.software.common.model.tool.ToolIOCase;
+import stirling.software.common.model.tool.ToolIOWhen;
+
+/**
+ * Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
+ * and the AI engine get it.
+ *
+ *
Also appends the {@code Input:/Output:/Type:} line the docs used to carry by hand, so the
+ * published text is unchanged without anyone maintaining it.
+ */
+@Component
+public class ToolIOOperationCustomizer
+ implements GlobalOperationCustomizer, GlobalOpenApiCustomizer {
+
+ public static final String EXTENSION_NAME = "x-stirling-io";
+ public static final String VOCABULARY_EXTENSION_NAME = "x-stirling-io-vocabulary";
+
+ // Published separately from the declarations: generators need the full vocabulary for their
+ // enums, and deriving it from what is present would shrink it when an endpoint is disabled.
+ @Override
+ public void customise(OpenAPI openApi) {
+ Map vocabulary = new LinkedHashMap<>();
+ vocabulary.put("formats", names(ToolFormat.values()));
+ vocabulary.put("arities", names(ToolArity.values()));
+ openApi.addExtension(VOCABULARY_EXTENSION_NAME, vocabulary);
+ }
+
+ @Override
+ public Operation customize(Operation operation, HandlerMethod handlerMethod) {
+ ToolIO declaration = handlerMethod.getMethodAnnotation(ToolIO.class);
+ if (declaration == null) {
+ return operation;
+ }
+ operation.addExtension(EXTENSION_NAME, toExtension(declaration));
+ operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
+ return operation;
+ }
+
+ private static Map toExtension(ToolIO declaration) {
+ Map extension = new LinkedHashMap<>();
+ extension.put("accepts", names(declaration.accepts()));
+ extension.put("produces", declaration.produces().name());
+ extension.put("arity", declaration.arity().name());
+ if (declaration.cases().length > 0) {
+ extension.put("cases", cases(declaration));
+ }
+ return extension;
+ }
+
+ private static List