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> cases(ToolIO declaration) { + return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList(); + } + + private static Map toCase(ToolIOCase rule) { + Map entry = new LinkedHashMap<>(); + entry.put( + "when", + Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList()); + entry.put("produces", rule.produces().name()); + entry.put("arity", rule.arity().name()); + return entry; + } + + private static Map toCondition(ToolIOWhen condition) { + Map entry = new LinkedHashMap<>(); + entry.put("param", condition.param()); + entry.put("matches", List.of(condition.matches())); + return entry; + } + + private static List names(Enum[] values) { + return Arrays.stream(values).map(Enum::name).toList(); + } + + private static String appendSummaryLine(String description, ToolIO declaration) { + String summary = + "Input:" + + String.join("/", names(declaration.accepts())) + + " Output:" + + declaration.produces().name() + + " Type:" + + declaration.arity().name(); + return description == null || description.isBlank() + ? summary + : description.trim() + " " + summary; + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolArity.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolArity.java new file mode 100644 index 0000000000..5966d4e181 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolArity.java @@ -0,0 +1,24 @@ +package stirling.software.common.model.tool; + +/** + * How many files an endpoint consumes and produces (Single/Multiple In, Single/Multiple Out). + * + *

This axis carries ZIP-as-transport: a multi-output endpoint returns its results zipped and the + * caller unpacks them, so {@code split-pages} is {@code produces = PDF, arity = SIMO} rather than + * naming a ZIP-of-PDF format. An endpoint whose deliverable really is an archive declares {@link + * ToolFormat#ZIP} with a single-output arity and stays packed. + */ +public enum ToolArity { + SISO, + SIMO, + MISO, + MIMO; + + public boolean isMultiInput() { + return this == MISO || this == MIMO; + } + + public boolean isMultiOutput() { + return this == SIMO || this == MIMO; + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolDiagnostic.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolDiagnostic.java new file mode 100644 index 0000000000..bc3184ba93 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolDiagnostic.java @@ -0,0 +1,47 @@ +package stirling.software.common.model.tool; + +/** + * One problem found while checking a chain, against the step that cannot run. {@code code} is + * stable so the frontend can pick its own wording; {@code message} is an English fallback. + */ +public record ToolDiagnostic(int stepIndex, Severity severity, String code, String message) { + + public enum Severity { + /** The chain cannot run as configured. Only this should block a save. */ + ERROR, + /** May not run, depending on configuration or file content. */ + WARN, + /** Worth knowing but not a problem, such as a step running once per file. */ + INFO + } + + /** The step declares no {@link ToolIO}, so nothing past it can be checked. */ + public static final String UNDECLARED = "undeclared-operation"; + + /** The previous step's output is not a format this step accepts. */ + public static final String FORMAT_MISMATCH = "format-mismatch"; + + /** The previous step's output depends on a parameter that is not set yet. */ + public static final String OUTPUT_UNCERTAIN = "output-uncertain"; + + /** The pipeline's input files are not a format the first step accepts. */ + public static final String SOURCE_MISMATCH = "source-mismatch"; + + /** The previous step emits several files and this one runs once per file. */ + public static final String FAN_OUT = "fan-out"; + + /** The previous step emits several files and this one consumes them in a single call. */ + public static final String FAN_IN = "fan-in"; + + public static ToolDiagnostic error(int stepIndex, String code, String message) { + return new ToolDiagnostic(stepIndex, Severity.ERROR, code, message); + } + + public static ToolDiagnostic warn(int stepIndex, String code, String message) { + return new ToolDiagnostic(stepIndex, Severity.WARN, code, message); + } + + public static ToolDiagnostic info(int stepIndex, String code, String message) { + return new ToolDiagnostic(stepIndex, Severity.INFO, code, message); + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolFormat.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolFormat.java new file mode 100644 index 0000000000..3e83c94a39 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolFormat.java @@ -0,0 +1,62 @@ +package stirling.software.common.model.tool; + +import java.util.List; + +import lombok.Getter; + +/** + * The kind of file a tool endpoint consumes or produces. + * + *

Encryption is its own format rather than a separate attribute, so the endpoints accepting only + * {@link #PDF} reject an encrypted one without declaring anything. + * + *

Extensions are a lossy projection used for run-time file checks: {@link #PDF} and {@link + * #PDF_ENCRYPTED} share {@code pdf}, because a filename cannot tell you whether a PDF is encrypted. + */ +@Getter +public enum ToolFormat { + PDF("pdf"), + PDF_ENCRYPTED("pdf"), + + // Vector formats are folded in: the extension set has always included svg/eps, and splitting + // them out would make chains that run fine today report as broken. + IMAGE("png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "psd", "ai", "eps"), + + /** + * An archive that is itself the deliverable. Multiple results use {@link ToolArity} instead. + */ + ZIP("zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "lzma", "z"), + + WORD("doc", "docx", "odt", "rtf"), + PPT("ppt", "pptx", "odp"), + EXCEL("xls", "xlsx", "ods"), + CSV("csv"), + HTML("html", "htm", "xhtml"), + XML("xml", "xsd", "xsl"), + JSON("json"), + TEXT("txt", "text", "md", "markdown"), + MARKDOWN("md", "markdown"), + JAVASCRIPT("js", "jsx"), + EBOOK("epub", "mobi", "azw3", "fb2", "txt", "docx"), + EMAIL("eml", "msg"), + POSTSCRIPT("ps", "eps"), + + PCL("pcl", "pxl"), + XPS("xps", "oxps"), + + VIDEO("mp4", "webm", "avi", "mov", "mkv"), + CBZ("cbz"), + CBR("cbr"), + + /** Never reported as incompatible. */ + ANY(), + + /** A report or a status rather than a document. */ + NONE(); + + private final List extensions; + + ToolFormat(String... extensions) { + this.extensions = List.of(extensions); + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIO.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIO.java new file mode 100644 index 0000000000..30325b4b62 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIO.java @@ -0,0 +1,30 @@ +package stirling.software.common.model.tool; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * What a tool endpoint consumes and produces, so a chain of steps can be checked before it runs. + * + *

The single source of truth: read off the handler method by {@code ToolIORegistry}, published + * into the OpenAPI spec as {@code x-stirling-io}, and generated from there into the frontend and + * the AI engine. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolIO { + + /** Defaulting to a plain PDF is what makes an ordinary endpoint reject an encrypted one. */ + ToolFormat[] accepts() default {ToolFormat.PDF}; + + ToolFormat produces(); + + ToolArity arity() default ToolArity.SISO; + + /** Overrides for an output that depends on a parameter; first match wins. */ + ToolIOCase[] cases() default {}; +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOCase.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOCase.java new file mode 100644 index 0000000000..b3af8c2348 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOCase.java @@ -0,0 +1,22 @@ +package stirling.software.common.model.tool; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * An output that applies when every condition in {@link #when()} holds. + * + *

Conditions are ANDed because the interesting branches turn on more than one parameter: Add + * Password only leaves the document unencrypted when both passwords are absent. + */ +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolIOCase { + + ToolIOWhen[] when(); + + ToolFormat produces(); + + ToolArity arity(); +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSource.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSource.java new file mode 100644 index 0000000000..1e0d896fbd --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSource.java @@ -0,0 +1,19 @@ +package stirling.software.common.model.tool; + +import java.util.Map; +import java.util.Optional; + +/** + * Supplies the {@link ToolIO} declaration for an endpoint path. An interface so a chain can be + * checked against a fixed set of declarations without standing up an application context. + */ +@FunctionalInterface +public interface ToolIOSource { + + Optional find(String operationPath); + + static ToolIOSource of(Map specs) { + Map copy = Map.copyOf(specs); + return path -> Optional.ofNullable(copy.get(path)); + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java new file mode 100644 index 0000000000..146ae85133 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java @@ -0,0 +1,108 @@ +package stirling.software.common.model.tool; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */ +public record ToolIOSpec( + Set accepts, ToolFormat produces, ToolArity arity, List cases) { + + public record When(String param, List matches) { + + boolean holdsFor(Object value) { + String normalised = normalise(value); + return matches.stream().anyMatch(match -> normalise(match).equals(normalised)); + } + } + + /** + * Both sides of a condition are normalised at comparison, not at construction: the declaration + * reaches the frontend and the engine as published data, and normalising only one side there + * would silently disagree with this one. + */ + public static String normalise(Object value) { + return value == null ? "" : String.valueOf(value).trim().toLowerCase(Locale.ROOT); + } + + public record Case(List when, ToolFormat produces, ToolArity arity) { + + public Case { + when = List.copyOf(when); + } + } + + /** {@code certain} is false when a {@link Case} keys on a parameter whose value is unknown. */ + public record Output(ToolFormat format, ToolArity arity, boolean certain) {} + + public ToolIOSpec { + accepts = Set.copyOf(accepts); + cases = List.copyOf(cases); + } + + public static ToolIOSpec from(ToolIO annotation) { + return new ToolIOSpec( + new LinkedHashSet<>(Arrays.asList(annotation.accepts())), + annotation.produces(), + annotation.arity(), + Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList()); + } + + private static Case toCase(ToolIOCase rule) { + List when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList(); + return new Case(when, rule.produces(), rule.arity()); + } + + private static When toWhen(ToolIOWhen condition) { + return new When(condition.param(), List.of(condition.matches())); + } + + /** + * First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the + * declared output comes back uncertain: a value we never saw might have picked another branch. + * + * @param parameters the step's configured parameters, or null when not known + */ + public Output resolveOutput(Map parameters) { + boolean sawUnknownParam = false; + for (Case rule : cases) { + boolean allHold = true; + for (When condition : rule.when()) { + if (parameters == null || !parameters.containsKey(condition.param())) { + sawUnknownParam = true; + allHold = false; + continue; + } + allHold &= condition.holdsFor(parameters.get(condition.param())); + } + if (allHold) { + return new Output(rule.produces(), rule.arity(), true); + } + } + return new Output(produces, arity, !sawUnknownParam); + } + + public Output resolveOutput() { + return resolveOutput(null); + } + + public boolean acceptsFormat(ToolFormat format) { + return format == ToolFormat.ANY + || accepts.contains(ToolFormat.ANY) + || accepts.contains(format); + } + + /** For run-time file checks. Empty means anything is accepted. */ + public List acceptedExtensions() { + if (accepts.contains(ToolFormat.ANY)) { + return List.of(); + } + return accepts.stream() + .flatMap(format -> format.getExtensions().stream()) + .distinct() + .toList(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOWhen.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOWhen.java new file mode 100644 index 0000000000..7202ed1126 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOWhen.java @@ -0,0 +1,18 @@ +package stirling.software.common.model.tool; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** One condition on a request parameter, guarding a {@link ToolIOCase}. */ +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolIOWhen { + + String param(); + + /** + * Compared as strings, case-insensitively. An empty string matches an absent or blank value. + */ + String[] matches(); +} diff --git a/app/common/src/main/java/stirling/software/common/service/ToolChainValidator.java b/app/common/src/main/java/stirling/software/common/service/ToolChainValidator.java new file mode 100644 index 0000000000..074a127925 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/ToolChainValidator.java @@ -0,0 +1,168 @@ +package stirling.software.common.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.tool.ToolDiagnostic; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIOSource; +import stirling.software.common.model.tool.ToolIOSpec; + +/** + * Whether a chain of steps can run: what each produces against what the next accepts. + * + *

The frontend and the AI engine implement the same rules against their generated copies, so a + * chain can be checked without a round trip. {@code testing/tool-io-cases.json} pins all three to + * the same answers. + */ +@Service +@RequiredArgsConstructor +public class ToolChainValidator { + + /** {@code parameters} may be null; only used to resolve an output that depends on one. */ + public record Step(String operation, Map parameters) {} + + private final ToolIOSource toolIO; + + public List validate(List steps) { + return validate(steps, null); + } + + /** + * @param sourceFormat the format entering step one, or null when unknown + */ + public List validate(List steps, ToolFormat sourceFormat) { + List diagnostics = new ArrayList<>(); + ToolIOSpec.Output carried = null; + + for (int i = 0; i < steps.size(); i++) { + Step step = steps.get(i); + Optional found = toolIO.find(step.operation()); + if (found.isEmpty()) { + diagnostics.add( + ToolDiagnostic.warn( + i, + ToolDiagnostic.UNDECLARED, + "Step " + + step.operation() + + " does not declare what it accepts or produces, so the" + + " rest of the chain cannot be checked.")); + // Nothing is known past an undeclared step. + carried = null; + continue; + } + ToolIOSpec spec = found.get(); + + // Only the first step is handed the pipeline's input. Every later step is handed the + // previous step's output, which is simply unknown once an undeclared step intervened - + // checking it against the input again would judge it on a format it never receives. + if (i == 0) { + checkSource(diagnostics, i, step, spec, sourceFormat); + } else if (carried != null) { + checkTransition(diagnostics, i, step, spec, carried); + } + carried = spec.resolveOutput(step.parameters()); + } + return diagnostics; + } + + public static boolean hasErrors(List diagnostics) { + return diagnostics.stream().anyMatch(d -> d.severity() == ToolDiagnostic.Severity.ERROR); + } + + private static void checkSource( + List diagnostics, + int index, + Step step, + ToolIOSpec spec, + ToolFormat sourceFormat) { + if (sourceFormat == null || spec.acceptsFormat(sourceFormat)) { + return; + } + diagnostics.add( + ToolDiagnostic.error( + index, + ToolDiagnostic.SOURCE_MISMATCH, + "Step " + + step.operation() + + " accepts " + + describe(spec) + + " but the pipeline's input is " + + sourceFormat + + ".")); + } + + private static void checkTransition( + List diagnostics, + int index, + Step step, + ToolIOSpec spec, + ToolIOSpec.Output previous) { + + if (previous.format() == ToolFormat.NONE) { + diagnostics.add( + ToolDiagnostic.error( + index, + ToolDiagnostic.FORMAT_MISMATCH, + "The previous step returns a report rather than a file, so " + + step.operation() + + " has nothing to run on.")); + return; + } + + if (!spec.acceptsFormat(previous.format())) { + String message = + "Step " + + step.operation() + + " accepts " + + describe(spec) + + " but the previous step produces " + + previous.format() + + "."; + diagnostics.add( + previous.certain() + ? ToolDiagnostic.error(index, ToolDiagnostic.FORMAT_MISMATCH, message) + // Unresolved output: may yet be fine once the step is configured. + : ToolDiagnostic.warn(index, ToolDiagnostic.OUTPUT_UNCERTAIN, message)); + return; + } + + if (!previous.certain()) { + diagnostics.add( + ToolDiagnostic.warn( + index, + ToolDiagnostic.OUTPUT_UNCERTAIN, + "The previous step's output depends on how it is configured, so this" + + " step may not be able to run.")); + return; + } + + if (previous.arity().isMultiOutput()) { + diagnostics.add( + spec.arity().isMultiInput() + ? ToolDiagnostic.info( + index, + ToolDiagnostic.FAN_IN, + "This step combines every file the previous step produced.") + : ToolDiagnostic.info( + index, + ToolDiagnostic.FAN_OUT, + "This step runs once for each file the previous step" + + " produced.")); + } + } + + private static String describe(ToolIOSpec spec) { + return spec.accepts().stream() + .map(Enum::name) + .sorted() + .reduce((a, b) -> a + " or " + b) + .orElse("nothing"); + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java b/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java new file mode 100644 index 0000000000..754bfb8019 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java @@ -0,0 +1,127 @@ +package stirling.software.common.service; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.model.tool.ToolIOSource; +import stirling.software.common.model.tool.ToolIOSpec; + +/** + * Reads every {@link ToolIO} declaration off its handler method at startup and serves it by + * endpoint path. Replaces parsing the same information out of the description prose, which meant + * fetching our own {@code /v1/api-docs} over HTTP first. + */ +@Slf4j +@Service +public class ToolIORegistry implements ToolMetadataService, ToolIOSource { + + private final ApplicationContext applicationContext; + + // Written on the startup thread, read on request threads. Spring's lifecycle establishes + // happens-before, so no volatile (same as AiEngineEndpointResolver). + private Map specsByPath = Map.of(); + + // Keep this the only constructor: with two, Spring falls back to a no-arg one that isn't here. + public ToolIORegistry(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + /** A registry over known declarations rather than ones discovered from the context. */ + static ToolIORegistry forSpecs(Map specs) { + ToolIORegistry registry = new ToolIORegistry(null); + registry.specsByPath = Map.copyOf(specs); + return registry; + } + + @EventListener(ContextRefreshedEvent.class) + public void discoverToolIO() { + Map discovered = new TreeMap<>(); + for (RequestMappingHandlerMapping mapping : + applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) { + mapping.getHandlerMethods() + .forEach((info, handler) -> register(discovered, info, handler)); + } + specsByPath = Map.copyOf(discovered); + log.debug("Discovered {} endpoints declaring @ToolIO", specsByPath.size()); + } + + private static void register( + Map target, RequestMappingInfo info, HandlerMethod handler) { + ToolIO annotation = handler.getMethodAnnotation(ToolIO.class); + if (annotation == null) { + return; + } + ToolIOSpec spec = ToolIOSpec.from(annotation); + for (String pattern : extractPatterns(info)) { + target.put(pattern, spec); + } + } + + @Override + public Optional find(String operationPath) { + return Optional.ofNullable(specsByPath.get(operationPath)); + } + + @Override + public boolean isMultiInput(String operationPath) { + return find(operationPath).map(spec -> spec.arity().isMultiInput()).orElse(false); + } + + @Override + public List getExtensionTypes(boolean output, String operationPath) { + Optional spec = find(operationPath); + if (spec.isEmpty()) { + return null; + } + List extensions = + output + ? spec.get().resolveOutput().format().getExtensions() + : spec.get().acceptedExtensions(); + // Callers express "no restriction" as null. + return extensions.isEmpty() ? null : extensions; + } + + @Override + public boolean shouldUnpackZipResponse(String operationPath) { + // Multi-output zips purely as transport. A single-output ZIP is the deliverable + // (extract-attachments) and stays packed. + return find(operationPath) + .map(spec -> spec.resolveOutput().arity().isMultiOutput()) + .orElse(false); + } + + private static Set extractPatterns(RequestMappingInfo info) { + try { + Method getDirectPaths = info.getClass().getMethod("getDirectPaths"); + Object result = getDirectPaths.invoke(info); + if (result instanceof Set set) { + Set patterns = new HashSet<>(); + for (Object value : set) { + if (value instanceof String s) { + patterns.add(s); + } + } + return patterns; + } + } catch (Exception e) { + log.trace("getDirectPaths unavailable on RequestMappingInfo", e); + } + return Set.of(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/ToolMetadataService.java b/app/common/src/main/java/stirling/software/common/service/ToolMetadataService.java index fb7a928d76..89fbc89f5a 100644 --- a/app/common/src/main/java/stirling/software/common/service/ToolMetadataService.java +++ b/app/common/src/main/java/stirling/software/common/service/ToolMetadataService.java @@ -17,12 +17,12 @@ public interface ToolMetadataService { List getExtensionTypes(boolean output, String operationPath); /** - * Returns true when the endpoint's ZIP response is a transport for multiple typed results and - * should be unpacked: multi-output endpoints (Type:SIMO / Type:MIMO) and wrapper declarations - * such as {@code Output:ZIP-PDF} or {@code Output:IMAGE/ZIP}. + * Returns true when the endpoint's ZIP response is a transport for several results and should + * be unpacked, which is exactly the multi-output endpoints (a {@code SIMO} or {@code MIMO} + * arity). * - *

Returns false for a bare {@code Output:ZIP} (e.g. {@code get-attachments}), where the - * archive itself is the deliverable and should be kept packed. + *

Returns false for an endpoint whose declared output is an archive in its own right (for + * example {@code extract-attachments}), where unpacking would discard the deliverable. */ boolean shouldUnpackZipResponse(String operationPath); } diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index 48e419a0ae..b4821edd9c 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -537,10 +537,6 @@ public final class RegexPatternUtils { getPattern("[/\\\\?%*:|\"<>]"); // Unsafe filename characters getPattern("[^a-zA-Z0-9 ]"); // Input sanitization getPattern("[^a-zA-Z0-9]"); // Filename sanitization - // API doc patterns - getPattern("Output:\\s*(\\w+)"); - getPattern("Input:\\s*(\\w+)"); - getPattern("Type:\\s*(\\w+)"); log.debug("Pre-compiled {} common regex patterns", patternCache.size()); } @@ -550,23 +546,6 @@ public final class RegexPatternUtils { "^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*(?:\\.[A-Za-z]{2,})$"); } - /* Pattern for matching Output: in API descriptions */ - public Pattern getApiDocOutputTypePattern() { - return getPattern("Output:\\s*(\\w+)"); - } - - /* Pattern for matching Input: in API descriptions */ - public Pattern getApiDocInputTypePattern() { - return getPattern("Input:\\s*(\\w+)"); - } - - /** - * Pattern for matching Type: in API descriptions - */ - public Pattern getApiDocTypePattern() { - return getPattern("Type:\\s*(\\w+)"); - } - /* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */ public Pattern getFileExtensionValidationPattern() { return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE); diff --git a/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java b/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java new file mode 100644 index 0000000000..67ba8be612 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java @@ -0,0 +1,159 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import stirling.software.common.model.tool.ToolArity; +import stirling.software.common.model.tool.ToolDiagnostic; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIOSource; +import stirling.software.common.model.tool.ToolIOSpec; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** The shared cases in {@code testing/tool-io-cases.json}, which all three implementations run. */ +class ToolChainValidatorConformanceTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @TestFactory + Stream sharedCases() throws IOException { + JsonNode root = MAPPER.readTree(Files.readString(casesFile())); + Map specs = readSpecs(root.get("specs")); + + List tests = new ArrayList<>(); + for (JsonNode testCase : root.get("cases")) { + tests.add( + DynamicTest.dynamicTest( + testCase.get("name").asString(), () -> runCase(testCase, specs))); + } + return tests.stream(); + } + + private static void runCase(JsonNode testCase, Map specs) { + Map registry = new HashMap<>(); + List steps = new ArrayList<>(); + + int index = 0; + for (JsonNode stepNode : testCase.get("steps")) { + // Each step gets its own path so the same spec can appear twice in a chain. + String operation = "/op/" + index++; + JsonNode specName = stepNode.get("spec"); + if (specName != null && !specName.isNull()) { + registry.put(operation, specs.get(specName.asString())); + } + steps.add(new ToolChainValidator.Step(operation, readParameters(stepNode))); + } + + JsonNode sourceNode = testCase.get("sourceFormat"); + ToolFormat sourceFormat = + sourceNode == null || sourceNode.isNull() + ? null + : ToolFormat.valueOf(sourceNode.asString()); + + List actual = + new ToolChainValidator(ToolIOSource.of(registry)).validate(steps, sourceFormat); + + assertEquals(summarise(testCase.get("expected")), summarise(actual), describe(actual)); + } + + private static Map readParameters(JsonNode stepNode) { + JsonNode parameters = stepNode.get("parameters"); + if (parameters == null || parameters.isNull()) { + return null; + } + Map values = new HashMap<>(); + parameters.propertyStream().forEach(e -> values.put(e.getKey(), e.getValue().asString())); + return values; + } + + private static Map readSpecs(JsonNode node) { + Map specs = new HashMap<>(); + node.propertyStream() + .forEach(entry -> specs.put(entry.getKey(), readSpec(entry.getValue()))); + return specs; + } + + private static ToolIOSpec readSpec(JsonNode node) { + Set accepts = new LinkedHashSet<>(); + for (JsonNode format : node.get("accepts")) { + accepts.add(ToolFormat.valueOf(format.asString())); + } + List cases = new ArrayList<>(); + for (JsonNode rule : node.get("cases")) { + List when = new ArrayList<>(); + for (JsonNode condition : rule.get("when")) { + List matches = new ArrayList<>(); + for (JsonNode match : condition.get("matches")) { + matches.add(match.asString()); + } + when.add(new ToolIOSpec.When(condition.get("param").asString(), matches)); + } + cases.add( + new ToolIOSpec.Case( + when, + ToolFormat.valueOf(rule.get("produces").asString()), + ToolArity.valueOf(rule.get("arity").asString()))); + } + return new ToolIOSpec( + accepts, + ToolFormat.valueOf(node.get("produces").asString()), + ToolArity.valueOf(node.get("arity").asString()), + cases); + } + + /** Messages are free text, so compare only the contractual parts. */ + private static List summarise(List diagnostics) { + return diagnostics.stream() + .map(d -> d.stepIndex() + ":" + d.severity() + ":" + d.code()) + .toList(); + } + + private static List summarise(JsonNode expected) { + List summary = new ArrayList<>(); + for (JsonNode node : expected) { + summary.add( + node.get("stepIndex").asInt() + + ":" + + node.get("severity").asString() + + ":" + + node.get("code").asString()); + } + return summary; + } + + private static String describe(List actual) { + return actual.stream() + .map(ToolDiagnostic::message) + .reduce((a, b) -> a + " | " + b) + .orElse("no diagnostics"); + } + + /** Shared with the frontend and engine, so it lives at the repo root. */ + private static Path casesFile() { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + Path candidate = current.resolve("testing/tool-io-cases.json"); + if (Files.exists(candidate)) { + return candidate; + } + current = current.getParent(); + } + throw new IllegalStateException( + "testing/tool-io-cases.json not found above the working directory"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/ToolIODiscoveryTest.java b/app/common/src/test/java/stirling/software/common/service/ToolIODiscoveryTest.java new file mode 100644 index 0000000000..4059382a55 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/ToolIODiscoveryTest.java @@ -0,0 +1,119 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +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.ToolIOSpec; +import stirling.software.common.model.tool.ToolIOWhen; + +/** + * The registry reads real {@code @ToolIO} annotations off real handler mappings. Everything else + * checks the logic against a hand-built map, which would still pass if discovery silently found + * nothing. + */ +class ToolIODiscoveryTest { + + @RestController + @RequestMapping("/api/v1/fixture") + static class FixtureController { + + @PostMapping("/rotate") + @ToolIO(produces = ToolFormat.PDF) + public String rotate() { + return ""; + } + + @PostMapping("/split") + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) + public String split() { + return ""; + } + + @PostMapping("/add-password") + @ToolIO( + produces = ToolFormat.PDF_ENCRYPTED, + cases = + @ToolIOCase( + when = { + @ToolIOWhen(param = "password", matches = ""), + @ToolIOWhen(param = "ownerPassword", matches = "") + }, + produces = ToolFormat.PDF, + arity = ToolArity.SISO)) + public String addPassword() { + return ""; + } + + @PostMapping("/undeclared") + public String undeclared() { + return ""; + } + } + + private static ToolIORegistry registry; + + @BeforeAll + static void discover() { + StaticWebApplicationContext context = new StaticWebApplicationContext(); + context.registerSingleton("fixtureController", FixtureController.class); + RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); + mapping.setApplicationContext(context); + mapping.afterPropertiesSet(); + context.getBeanFactory().registerSingleton("requestMappingHandlerMapping", mapping); + + registry = new ToolIORegistry(context); + registry.discoverToolIO(); + } + + @Test + void readsDeclarationsOffHandlerMethods() { + ToolIOSpec rotate = registry.find("/api/v1/fixture/rotate").orElseThrow(); + assertEquals(ToolFormat.PDF, rotate.produces()); + assertEquals(ToolArity.SISO, rotate.arity()); + assertTrue(rotate.acceptsFormat(ToolFormat.PDF)); + assertFalse(rotate.acceptsFormat(ToolFormat.PDF_ENCRYPTED)); + } + + @Test + void skipsMethodsWithNoDeclaration() { + assertTrue(registry.find("/api/v1/fixture/undeclared").isEmpty()); + } + + @Test + void carriesArityThroughToTheUnpackDecision() { + assertTrue(registry.shouldUnpackZipResponse("/api/v1/fixture/split")); + assertFalse(registry.shouldUnpackZipResponse("/api/v1/fixture/rotate")); + } + + @Test + void carriesCasesThroughToOutputResolution() { + ToolIOSpec spec = registry.find("/api/v1/fixture/add-password").orElseThrow(); + assertEquals( + ToolFormat.PDF_ENCRYPTED, + spec.resolveOutput(Map.of("password", "x", "ownerPassword", "")).format()); + assertEquals( + ToolFormat.PDF, + spec.resolveOutput(Map.of("password", "", "ownerPassword", "")).format()); + } + + @Test + void exposesInputExtensionsForRunTimeFileChecks() { + assertEquals(List.of("pdf"), registry.getExtensionTypes(false, "/api/v1/fixture/rotate")); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/ToolIORegistryTest.java b/app/common/src/test/java/stirling/software/common/service/ToolIORegistryTest.java new file mode 100644 index 0000000000..2ff60bfce7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/ToolIORegistryTest.java @@ -0,0 +1,87 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.tool.ToolArity; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIOSpec; + +/** The {@link ToolMetadataService} behaviour the pipeline executors depend on. */ +class ToolIORegistryTest { + + private static final String SPLIT = "/api/v1/general/split-pages"; + private static final String MERGE = "/api/v1/general/merge-pdfs"; + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final String ATTACHMENTS = "/api/v1/security/get-attachments"; + private static final String EXTRACT_IMAGES = "/api/v1/misc/extract-images"; + private static final String CONVERT_ANY = "/api/v1/convert/file/pdf"; + private static final String UNKNOWN = "/api/v1/general/does-not-exist"; + + private static ToolIOSpec spec(ToolFormat accepts, ToolFormat produces, ToolArity arity) { + return new ToolIOSpec(Set.of(accepts), produces, arity, List.of()); + } + + private final ToolIORegistry registry = + ToolIORegistry.forSpecs( + Map.of( + SPLIT, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.SIMO), + MERGE, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.MISO), + ROTATE, spec(ToolFormat.PDF, ToolFormat.PDF, ToolArity.SISO), + ATTACHMENTS, spec(ToolFormat.PDF, ToolFormat.ZIP, ToolArity.SISO), + EXTRACT_IMAGES, spec(ToolFormat.PDF, ToolFormat.IMAGE, ToolArity.SIMO), + CONVERT_ANY, spec(ToolFormat.ANY, ToolFormat.PDF, ToolArity.SISO))); + + @Test + void multiInputFollowsArity() { + assertTrue(registry.isMultiInput(MERGE)); + assertFalse(registry.isMultiInput(SPLIT)); + assertFalse(registry.isMultiInput(ROTATE)); + assertFalse(registry.isMultiInput(UNKNOWN)); + } + + @Test + void inputExtensionsComeFromAcceptedFormats() { + assertEquals(List.of("pdf"), registry.getExtensionTypes(false, ROTATE)); + } + + @Test + void outputExtensionsComeFromTheProducedFormat() { + assertEquals(List.of("pdf"), registry.getExtensionTypes(true, ROTATE)); + assertTrue(registry.getExtensionTypes(true, EXTRACT_IMAGES).contains("png")); + } + + @Test + void noRestrictionIsReportedAsNull() { + // Callers treat null as "any type accepted". + assertNull(registry.getExtensionTypes(false, CONVERT_ANY)); + assertNull(registry.getExtensionTypes(false, UNKNOWN)); + } + + @Test + void multiOutputResponsesAreUnpacked() { + assertTrue(registry.shouldUnpackZipResponse(SPLIT)); + assertTrue(registry.shouldUnpackZipResponse(EXTRACT_IMAGES)); + } + + @Test + void anArchiveDeliverableStaysPacked() { + // The archive is the deliverable; unpacking would lose it. + assertFalse(registry.shouldUnpackZipResponse(ATTACHMENTS)); + } + + @Test + void singleOutputResponsesAreNotUnpacked() { + assertFalse(registry.shouldUnpackZipResponse(ROTATE)); + assertFalse(registry.shouldUnpackZipResponse(MERGE)); + assertFalse(registry.shouldUnpackZipResponse(UNKNOWN)); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java index 217f18abf5..6ae84a8e6d 100644 --- a/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java @@ -325,11 +325,8 @@ class RegexPatternUtilsMoreTest { } @Test - void pageModeAndApiDocPatterns() { + void pageModePattern() { assertTrue(utils.getPageModePattern().matcher("a/b").find()); - assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find()); - assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find()); - assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find()); } @Test diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java index 7dc79a3ebc..275bc3f967 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java @@ -40,7 +40,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get PDF page count", - description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") + description = "Returns total number of pages in PDF.") public ResponseEntity getPageCount(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { return ResponseEntity.ok(Map.of("pageCount", document.getNumberOfPages())); @@ -54,7 +54,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get basic PDF information", - description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") + description = "Returns page count, version, file size.") public ResponseEntity getBasicInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map info = new HashMap<>(); @@ -72,7 +72,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get PDF document properties", - description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") + description = "Returns title, author, subject, etc.") public ResponseEntity getDocumentProperties(@ModelAttribute PDFFile file) throws IOException { // Load the document in read-only mode to prevent modifications and ensure the integrity of @@ -105,7 +105,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get page dimensions for all pages", - description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") + description = "Returns width and height of each page.") public ResponseEntity getPageDimensions(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { List> dimensions = new ArrayList<>(); @@ -128,8 +128,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get form field information", - description = - "Returns count and details of form fields. Input:PDF Output:JSON Type:SISO") + description = "Returns count and details of form fields.") public ResponseEntity getFormFields(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map formInfo = new HashMap<>(); @@ -155,7 +154,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get annotation information", - description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") + description = "Returns count and types of annotations.") public ResponseEntity getAnnotationInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map annotInfo = new HashMap<>(); @@ -183,8 +182,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get font information", - description = - "Returns list of fonts used in the document. Input:PDF Output:JSON Type:SISO") + description = "Returns list of fonts used in the document.") public ResponseEntity getFontInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map fontInfo = new HashMap<>(); @@ -212,8 +210,7 @@ public class AnalysisController { @JsonDataResponse @Operation( summary = "Get security information", - description = - "Returns encryption and permission details. Input:PDF Output:JSON Type:SISO") + description = "Returns encryption and permission details.") public ResponseEntity getSecurityInfo(@ModelAttribute PDFFile file) throws IOException { try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) { Map securityInfo = new HashMap<>(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java index d1145fa815..f777680223 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java @@ -29,6 +29,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.api.general.BookletImpositionRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFileManager; @@ -47,12 +49,13 @@ public class BookletImpositionController { value = "/booklet-imposition", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Create a booklet with proper page imposition", description = - "This operation combines page reordering for booklet printing with multi-page layout. " - + "It rearranges pages in the correct order for booklet printing and places multiple pages " - + "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO") + "This operation combines page reordering for booklet printing with multi-page" + + " layout. It rearranges pages in the correct order for booklet printing and" + + " places multiple pages on each sheet for proper folding and binding.") public ResponseEntity createBookletImposition( @ModelAttribute BookletImpositionRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index f5a0cdcba9..571bb42914 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -27,6 +27,8 @@ import stirling.software.SPDF.model.api.general.CropPdfForm; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -131,11 +133,12 @@ public class CropController { value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @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") + + " coordinates.") public ResponseEntity cropPdf(@ModelAttribute CropPdfForm request) throws IOException { if (request.isAutoCrop()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java index 53abc46ac6..0cd187f2ec 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java @@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.EditTableOfContentsRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFileManager; @@ -47,6 +49,7 @@ public class EditTableOfContentsController { value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.JSON) @Operation( summary = "Extract PDF Bookmarks", description = "Extracts bookmarks/table of contents from a PDF document as JSON.") @@ -151,6 +154,7 @@ public class EditTableOfContentsController { value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Edit Table of Contents", description = "Add or edit bookmarks/table of contents in a PDF document.") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 3a10b1419b..17d1d7d8a7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -34,6 +34,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.general.EditTextOperation; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; @@ -83,19 +85,18 @@ public class EditTextController { value = "/edit-text", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Edit text in a PDF via find and replace", description = "Applies an ordered list of find/replace operations to the text in a PDF and" - + " returns the edited PDF. Useful for find-and-replace, bulk renames" - + " (e.g. updating a company name throughout a document), and copy" - + " editing where the AI agent has identified specific replacements." - + " Matching is performed against the joined text of each page, so" - + " find strings can span multiple visual runs (titles split per word," - + " kerning-broken phrases). Cross-element matches are written as a" - + " single replacement run anchored at the leftmost matched position;" - + " centered or tracked text may shift left when its content changes." - + " Input:PDF Output:PDF Type:SISO") + + " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g." + + " updating a company name throughout a document), and copy editing where the AI" + + " agent has identified specific replacements. Matching is performed against the" + + " joined text of each page, so find strings can span multiple visual runs" + + " (titles split per word, kerning-broken phrases). Cross-element matches are" + + " written as a single replacement run anchored at the leftmost matched position;" + + " centered or tracked text may shift left when its content changes.") public ResponseEntity editText(@ModelAttribute EditTextRequest request) throws Exception { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 9408aa821f..db26ca58d1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -41,6 +41,9 @@ import stirling.software.SPDF.model.api.general.MergePdfsRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -265,12 +268,13 @@ public class MergeController { value = "/merge-pdfs", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.MISO) @Operation( summary = "Merge multiple PDF files into one", description = "This endpoint merges multiple PDF files into a single PDF file. The merged" + " file will contain all pages from the input files in the order they were" - + " provided. Input:PDF Output:PDF Type:MISO") + + " provided.") public ResponseEntity mergePdfs( @ModelAttribute MergePdfsRequest request, @RequestParam(value = "fileOrder", required = false) String fileOrder) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index a18d0a81c0..e9d49ef262 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralFormCopyUtils; @@ -44,11 +46,12 @@ public class MultiPageLayoutController { value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Merge multiple pages of a PDF document into a single page", description = "This operation takes an input PDF file and the number of pages to merge into a" - + " single sheet in the output PDF file. Input:PDF Output:PDF Type:SISO") + + " single sheet in the output PDF file.") public ResponseEntity mergeMultiplePagesIntoOne( @ModelAttribute MergeMultiplePagesRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index 4aab03ff55..7f17738d98 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -27,6 +27,9 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -46,11 +49,12 @@ public class PdfOverlayController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.MISO) @Operation( summary = "Overlay PDF files in various modes", description = "Overlay PDF files onto a base PDF with different modes: Sequential," - + " Interleaved, or Fixed Repeat. Input:PDF Output:PDF Type:MIMO") + + " Interleaved, or Fixed Repeat.") public ResponseEntity overlayPdfs(@ModelAttribute OverlayPdfsRequest request) throws IOException { MultipartFile baseFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PosterPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PosterPdfController.java index 80f1a159da..24aa4a2c80 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PosterPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PosterPdfController.java @@ -29,6 +29,9 @@ import stirling.software.SPDF.model.api.general.PosterPdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -49,13 +52,13 @@ public class PosterPdfController { consumes = "multipart/form-data", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Split large PDF pages into smaller printable chunks", description = - "This endpoint splits large or oddly-sized PDF pages into smaller chunks " - + "suitable for printing on standard paper sizes (e.g., A4, Letter). " - + "Divides each page into a grid of smaller pages using Apache PDFBox. " - + "Input: PDF Output: ZIP-PDF Type: SISO") + "This endpoint splits large or oddly-sized PDF pages into smaller chunks" + + " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each" + + " page into a grid of smaller pages using Apache PDFBox.") public ResponseEntity posterPdf(@ModelAttribute PosterPdfRequest request) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java index 12e3a15b89..4c45951644 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java @@ -30,6 +30,8 @@ import stirling.software.SPDF.model.api.general.RearrangePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; @@ -50,12 +52,12 @@ public class RearrangePagesPDFController { value = "/remove-pages", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Remove pages from a PDF file", description = "This endpoint removes specified pages from a given PDF file. Users can provide" - + " a comma-separated list of page numbers or ranges to delete. Input:PDF" - + " Output:PDF Type:SISO") + + " a comma-separated list of page numbers or ranges to delete.") public ResponseEntity deletePages(@ModelAttribute PDFWithPageNums request) throws IOException { @@ -234,13 +236,13 @@ public class RearrangePagesPDFController { value = "/rearrange-pages", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Rearrange pages in a PDF file", description = "This endpoint rearranges pages in a given PDF file based on the specified page" - + " order or custom mode. Users can provide a page order as a" - + " comma-separated list of page numbers or page ranges, or a custom mode." - + " Input:PDF Output:PDF") + + " order or custom mode. Users can provide a page order as a comma-separated list" + + " of page numbers or page ranges, or a custom mode.") public ResponseEntity rearrangePages(@ModelAttribute RearrangePagesRequest request) throws IOException { MultipartFile pdfFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java index 7dbb891197..31719bad75 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RotationController.java @@ -20,6 +20,8 @@ import stirling.software.SPDF.model.api.general.RotatePDFRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -38,11 +40,12 @@ public class RotationController { value = "/rotate-pdf", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Rotate a PDF file", description = "This endpoint rotates a given PDF file by a specified angle. The angle must be" - + " a multiple of 90. Input:PDF Output:PDF Type:SISO") + + " a multiple of 90.") public ResponseEntity rotatePDF(@ModelAttribute RotatePDFRequest request) throws IOException { MultipartFile pdfFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java index fb7a55b793..6aa8599eea 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ScalePagesController.java @@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -87,11 +89,12 @@ public class ScalePagesController { value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Change the size of a PDF page/document", description = "This operation takes an input PDF file and the size to scale the pages to in" - + " the output PDF file. Input:PDF Output:PDF Type:SISO") + + " the output PDF file.") public ResponseEntity scalePages(@ModelAttribute ScalePagesRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java index d4516c0ed6..712a123581 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java @@ -30,6 +30,9 @@ import stirling.software.SPDF.model.api.SplitPagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; @@ -52,13 +55,13 @@ public class SplitPDFController { value = "/split-pages", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Split a PDF file into separate documents", description = "This endpoint splits a given PDF file into separate documents based on the" - + " specified page numbers or ranges. Users can specify pages using" - + " individual numbers, ranges, or 'all' for every page. Input:PDF" - + " Output:PDF Type:SIMO") + + " specified page numbers or ranges. Users can specify pages using individual" + + " numbers, ranges, or 'all' for every page.") public ResponseEntity splitPdf(@ModelAttribute SplitPagesRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java index 6ad85d0c92..d4b07c3add 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java @@ -34,6 +34,9 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.PdfMetadata; +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.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.util.ExceptionUtils; @@ -93,11 +96,10 @@ public class SplitPdfByChaptersController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Split PDFs by Chapters", - description = - "Splits a PDF into chapters and returns a ZIP file. Input:PDF Output:ZIP-PDF" - + " Type:SISO") + description = "Splits a PDF into chapters and returns a ZIP file.") public ResponseEntity splitPdf(@ModelAttribute SplitPdfByChaptersRequest request) throws Exception { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java index adaed9ec35..be8ca183a7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java @@ -34,6 +34,9 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -54,13 +57,13 @@ public class SplitPdfBySectionsController { value = "/split-pdf-by-sections", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Split PDF pages into smaller sections", description = "Split each page of a PDF into smaller sections based on the user's choice" - + " which page to split, and how to split" - + " ( halves, thirds, quarters, etc.), both vertically and horizontally." - + " Input:PDF Output:ZIP-PDF Type:SISO") + + " which page to split, and how to split ( halves, thirds, quarters, etc.), both" + + " vertically and horizontally.") public ResponseEntity splitPdf( @Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index e8ff5d9e23..8721fd3b70 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -29,6 +29,9 @@ import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; @@ -52,14 +55,14 @@ public class SplitPdfBySizeController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Auto split PDF pages into separate documents based on size or count", description = "split PDF into multiple paged documents based on size/count, ie if 20 pages" - + " and split into 5, it does 5 documents each 4 pages\r\n" - + " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB" - + " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF" - + " Output:ZIP-PDF Type:SISO") + + " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page" + + " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts" + + " 1.9MB but not 2.1MB)") public ResponseEntity autoSplitPdf( @ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java index d451e98f5e..08d49c579f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/ToSinglePageController.java @@ -22,6 +22,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFileManager; @@ -39,13 +41,13 @@ public class ToSinglePageController { value = "/pdf-to-single-page", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Convert a multi-page PDF into a single long page PDF", description = "This endpoint converts a multi-page PDF document into a single paged PDF" - + " document. The width of the single page will be same as the input's" - + " width, but the height will be the sum of all the pages' heights." - + " Input:PDF Output:PDF Type:SISO") + + " document. The width of the single page will be same as the input's width, but" + + " the height will be the sum of all the pages' heights.") public ResponseEntity pdfToSinglePage(@ModelAttribute PDFFile request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java index a53ae4943c..a835fc4307 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEbookToPDFController.java @@ -29,6 +29,8 @@ import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; @@ -61,11 +63,12 @@ public class ConvertEbookToPDFController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(accepts = ToolFormat.EBOOK, produces = ToolFormat.PDF) @Operation( summary = "Convert an eBook file to PDF", description = "This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)" - + " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO") + + " to PDF using Calibre.") public ResponseEntity convertEbookToPdf( @ModelAttribute ConvertEbookToPdfRequest request) throws Exception { if (!isCalibreEnabled()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java index 7c3978ca00..446892b0fc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertEmlToPDF.java @@ -27,6 +27,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.CustomHtmlSanitizer; import stirling.software.common.util.EmlToPdf; @@ -49,13 +51,14 @@ public class ConvertEmlToPDF { value = "/eml/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(accepts = ToolFormat.EMAIL, produces = ToolFormat.PDF) @Operation( summary = "Convert EML/MSG to PDF", description = - "This endpoint converts EML (email) and MSG (Outlook) files to PDF format" - + " with extensive customization options. Features include font settings," - + " image constraints, display modes, attachment handling, and HTML debug" - + " output. Input: EML or MSG file, Output: PDF or HTML file. Type: SISO") + "This endpoint converts EML (email) and MSG (Outlook) files to PDF format with" + + " extensive customization options. Features include font settings, image" + + " constraints, display modes, attachment handling, and HTML debug output. or MSG" + + " file, or HTML file.") public ResponseEntity convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java index 53a0def649..c7799497a5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java @@ -19,6 +19,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.converters.HTMLToPdfRequest; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.*; @@ -39,11 +41,14 @@ public class ConvertHtmlToPDF { value = "/html/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + // A ZIP of HTML plus its CSS is a first-class input here, and is what convert/pdf/html emits. + @ToolIO( + accepts = {ToolFormat.HTML, ToolFormat.ZIP}, + produces = ToolFormat.PDF) @Operation( summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF", description = - "This endpoint takes an HTML or ZIP file input and converts it to a PDF format." - + " Input:HTML Output:PDF Type:SISO") + "This endpoint takes an HTML or ZIP file input and converts it to a PDF format.") public ResponseEntity HtmlToPdf(@ModelAttribute HTMLToPdfRequest request) throws Exception { MultipartFile fileInput = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 1255a2a228..40f1c8be45 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -41,6 +41,11 @@ import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.CbrUtils; import stirling.software.common.util.CbzUtils; @@ -78,12 +83,20 @@ public class ConvertImgPDFController { value = "/pdf/img", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO( + produces = ToolFormat.IMAGE, + arity = ToolArity.SIMO, + cases = + @ToolIOCase( + when = @ToolIOWhen(param = "singleOrMultiple", matches = "single"), + produces = ToolFormat.IMAGE, + arity = ToolArity.SISO)) @Operation( summary = "Convert PDF to image(s)", description = "This endpoint converts a PDF file to image(s) with the specified image format," + " color type, and DPI. Users can choose to get a single image or multiple" - + " images. Input:PDF Output:Image Type:SI-Conditional") + + " images.") public ResponseEntity convertToImage(@ModelAttribute ConvertToImageRequest request) throws Exception { MultipartFile file = request.getFileInput(); @@ -248,12 +261,13 @@ public class ConvertImgPDFController { value = "/img/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(accepts = ToolFormat.IMAGE, produces = ToolFormat.PDF, arity = ToolArity.MISO) @Operation( summary = "Convert images to a PDF file", description = "This endpoint converts one or more images to a PDF file. Users can specify" + " whether to stretch the images to fit the PDF page, and whether to" - + " automatically rotate the images. Input:Image Output:PDF Type:MISO") + + " automatically rotate the images.") public ResponseEntity convertToPdf(@ModelAttribute ConvertToPdfRequest request) throws IOException { MultipartFile[] file = request.getFileInput(); @@ -279,11 +293,10 @@ public class ConvertImgPDFController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(accepts = ToolFormat.CBZ, produces = ToolFormat.PDF) @Operation( summary = "Convert CBZ comic book archive to PDF", - description = - "This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. " - + "Input:CBZ Output:PDF Type:SISO") + description = "This endpoint converts a CBZ (ZIP) comic book archive to a PDF file.") public ResponseEntity convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request) throws IOException { MultipartFile file = request.getFileInput(); @@ -308,11 +321,10 @@ public class ConvertImgPDFController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.CBZ) @Operation( summary = "Convert PDF to CBZ comic book archive", - description = - "This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. " - + "Input:PDF Output:CBZ Type:SISO") + description = "This endpoint converts a PDF file to a CBZ (ZIP) comic book archive.") public ResponseEntity convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request) throws IOException { MultipartFile file = request.getFileInput(); @@ -334,11 +346,10 @@ public class ConvertImgPDFController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(accepts = ToolFormat.CBR, produces = ToolFormat.PDF) @Operation( summary = "Convert CBR comic book archive to PDF", - description = - "This endpoint converts a CBR (RAR) comic book archive to a PDF file. " - + "Input:CBR Output:PDF Type:SISO") + description = "This endpoint converts a CBR (RAR) comic book archive to a PDF file.") public ResponseEntity convertCbrToPdf(@ModelAttribute ConvertCbrToPdfRequest request) throws IOException { MultipartFile file = request.getFileInput(); @@ -363,11 +374,12 @@ public class ConvertImgPDFController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.CBR) @Operation( summary = "Convert PDF to CBR comic book archive", description = - "This endpoint converts a PDF file to a CBR comic book archive using the local RAR CLI. " - + "Input:PDF Output:CBR Type:SISO") + "This endpoint converts a PDF file to a CBR comic book archive using the local" + + " RAR CLI.") public ResponseEntity convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java index e47f3322ea..c9c95d6ef2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java @@ -28,6 +28,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.*; @@ -47,11 +49,15 @@ public class ConvertMarkdownToPdf { value = "/markdown/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + // A ZIP of Markdown plus its images is a first-class input here, not just a bare .md file. + @ToolIO( + accepts = {ToolFormat.MARKDOWN, ToolFormat.ZIP}, + produces = ToolFormat.PDF) @Operation( summary = "Convert a Markdown file to PDF", description = - "This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to" - + " PDF format. Input:MARKDOWN Output:PDF Type:SISO") + "This endpoint takes a Markdown file or ZIP (containing Markdown + images)" + + " input, converts it to HTML, and then to PDF format.") public ResponseEntity markdownToPdf(@ModelAttribute GeneralFile generalFile) throws Exception { MultipartFile fileInput = generalFile.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java index 4fc6669bc7..97552000f2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java @@ -31,6 +31,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.CustomHtmlSanitizer; import stirling.software.common.util.ExceptionUtils; @@ -209,11 +211,10 @@ public class ConvertOfficeController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/file/pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(accepts = ToolFormat.ANY, produces = ToolFormat.PDF) @Operation( summary = "Convert a file to a PDF using LibreOffice", - description = - "This endpoint converts a given file to a PDF using LibreOffice API Input:ANY" - + " Output:PDF Type:SISO") + description = "This endpoint converts a given file to a PDF using LibreOffice API") public ResponseEntity processFileToPDF(@ModelAttribute GeneralFile generalFile) throws Exception { MultipartFile inputFile = generalFile.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java index 7b2de8d11b..c95c2b932f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java @@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.Targe import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @@ -86,11 +88,10 @@ public class ConvertPDFToEpubController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.EBOOK) @Operation( summary = "Convert PDF to EPUB/AZW3", - description = - "Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF" - + " Output:EPUB/AZW3 Type:SISO") + description = "Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre.") public ResponseEntity convertPdfToEpub( @ModelAttribute ConvertPdfToEpubRequest request) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java index 59e21138e6..f747ce7447 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelController.java @@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; @@ -50,11 +52,12 @@ public class ConvertPDFToExcelController { value = "/pdf/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.EXCEL) @Operation( summary = "Convert a PDF to an Excel spreadsheet (XLSX)", description = "Extracts tabular data from each page of a PDF and writes it into an Excel" - + " workbook, one sheet per table. Input:PDF Output:XLSX Type:SISO") + + " workbook, one sheet per table.") public ResponseEntity pdfToExcel(@ModelAttribute PDFWithPageNums request) throws Exception { String baseName = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java index 20253a3a00..8f9819c0e4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java @@ -15,6 +15,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.PDFToFile; import stirling.software.common.util.TempFileManager; @@ -29,10 +31,10 @@ public class ConvertPDFToHtml { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.ZIP) @Operation( summary = "Convert PDF to HTML", - description = - "This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO") + description = "This endpoint converts a PDF file to HTML format.") public ResponseEntity processPdfToHTML(@ModelAttribute PDFFile file) throws Exception { MultipartFile inputFile = file.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java index 0fd7dee9a7..fd6218de0d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOffice.java @@ -24,6 +24,11 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.PDFToFile; @@ -43,11 +48,10 @@ public class ConvertPDFToOffice { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PPT) @Operation( summary = "Convert PDF to Presentation format", - description = - "This endpoint converts a given PDF file to a Presentation format. Input:PDF" - + " Output:PPT Type:SISO") + description = "This endpoint converts a given PDF file to a Presentation format.") public ResponseEntity processPdfToPresentation( @ModelAttribute PdfToPresentationRequest request) throws IOException, InterruptedException { @@ -61,11 +65,16 @@ public class ConvertPDFToOffice { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO( + produces = ToolFormat.TEXT, + cases = + @ToolIOCase( + when = @ToolIOWhen(param = "outputFormat", matches = "rtf"), + produces = ToolFormat.WORD, + arity = ToolArity.SISO)) @Operation( summary = "Convert PDF to Text or RTF format", - description = - "This endpoint converts a given PDF file to Text or RTF format. Input:PDF" - + " Output:TXT Type:SISO") + description = "This endpoint converts a given PDF file to Text or RTF format.") public ResponseEntity processPdfToRTForTXT( @ModelAttribute PdfToTextOrRTFRequest request) throws IOException, InterruptedException { @@ -94,11 +103,10 @@ public class ConvertPDFToOffice { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.WORD) @Operation( summary = "Convert PDF to Word document", - description = - "This endpoint converts a given PDF file to a Word document format. Input:PDF" - + " Output:WORD Type:SISO") + description = "This endpoint converts a given PDF file to a Word document format.") public ResponseEntity processPdfToWord(@ModelAttribute PdfToWordRequest request) throws IOException, InterruptedException { MultipartFile inputFile = request.getFileInput(); @@ -111,11 +119,10 @@ public class ConvertPDFToOffice { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.XML) @Operation( summary = "Convert PDF to XML", - description = - "This endpoint converts a PDF file to an XML file. Input:PDF Output:XML" - + " Type:SISO") + description = "This endpoint converts a PDF file to an XML file.") public ResponseEntity processPdfToXML(@ModelAttribute PDFFile file) throws Exception { MultipartFile inputFile = file.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index a58d466d7a..49bf4e4895 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -91,6 +91,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @@ -577,10 +579,13 @@ public class ConvertPDFToPDFA { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Convert a PDF to a PDF/A or PDF/X", description = - "This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for long-term archiving, while PDF/X is optimized for print production. Input:PDF Output:PDF Type:SISO") + "This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript" + + " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for" + + " long-term archiving, while PDF/X is optimized for print production.") public ResponseEntity pdfToPdfA(@ModelAttribute PdfToPdfARequest request) throws Exception { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java index ccac814ef7..6aeb033761 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonController.java @@ -62,7 +62,7 @@ public class ConvertPdfJsonController { @Operation( summary = "Convert PDF to Text Editor Format", description = - "Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO") + "Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.") public ResponseEntity convertPdfToJson( @ModelAttribute PDFFile request, @RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight) @@ -104,7 +104,7 @@ public class ConvertPdfJsonController { @Operation( summary = "Convert Text Editor Format to PDF", description = - "Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO") + "Rebuilds a PDF from the editable JSON structure generated by the text editor tool.") public ResponseEntity convertJsonToPdf(@ModelAttribute GeneralFile request) throws Exception { MultipartFile jsonFile = request.getFileInput(); @@ -139,7 +139,7 @@ public class ConvertPdfJsonController { description = "Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for" + " subsequent page requests. Returns a server-generated jobId scoped to the" - + " authenticated user. Input:PDF Output:JSON Type:SISO") + + " authenticated user.") public ResponseEntity extractPdfMetadata(@ModelAttribute PDFFile request) throws Exception { MultipartFile inputFile = request.getFileInput(); @@ -226,7 +226,7 @@ public class ConvertPdfJsonController { description = "Retrieves a single page's content from a previously cached PDF document for the text editor tool." + " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the" - + " authenticated user. Output:JSON") + + " authenticated user.") public ResponseEntity extractSinglePage( @PathVariable String jobId, @PathVariable int pageNumber) throws Exception { @@ -255,7 +255,7 @@ public class ConvertPdfJsonController { description = "Retrieves the font payloads used by a single page from a previously cached PDF document." + " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the" - + " authenticated user. Output:JSON") + + " authenticated user.") public ResponseEntity extractPageFonts( @PathVariable String jobId, @PathVariable int pageNumber) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java index 8f5d2a4817..df5a91ad67 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java @@ -56,8 +56,7 @@ public class ConvertPdfToVideoController { @Operation( summary = "Convert PDF to Video Slideshow", description = - "This endpoint converts a PDF document into a slideshow-style video." - + " Input:PDF Output:Video Type:SISO") + "This endpoint converts a PDF document into a slideshow-style video.") public ResponseEntity convertPdfToVideo(@ModelAttribute PdfToVideoRequest request) throws Exception { if (!CheckProgramInstall.isFfmpegAvailable()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java index 314dd28271..94a9ed44f2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java @@ -29,6 +29,11 @@ import stirling.software.SPDF.utils.SvgToPdf; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.SvgSanitizer; @@ -50,15 +55,23 @@ public class ConvertSvgToPDF { value = "/svg/pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO( + accepts = ToolFormat.IMAGE, + produces = ToolFormat.PDF, + arity = ToolArity.MIMO, + cases = + @ToolIOCase( + when = @ToolIOWhen(param = "combineIntoSinglePdf", matches = "true"), + produces = ToolFormat.PDF, + arity = ToolArity.MISO)) @Operation( summary = "Convert SVG to PDF", description = - "This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF format. " - + "Each SVG is converted to a separate PDF file. " - + "The conversion preserves vector graphics for crisp output at any resolution - no rasterization occurs. " - + "SVG dimensions (width/height) determine the PDF page size; defaults to A4 if not specified. " - + "SVG content is sanitized to prevent XSS attacks. " - + "Input: SVG file(s), Output: PDF file(s) or ZIP. Type: MIMO") + "This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF" + + " format. Each SVG is converted to a separate PDF file. The conversion preserves" + + " vector graphics for crisp output at any resolution - no rasterization occurs." + + " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if" + + " not specified. SVG content is sanitized to prevent XSS attacks.") public ResponseEntity convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) { MultipartFile[] inputFiles = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java index c07525f06c..1902dcf053 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java @@ -34,6 +34,8 @@ import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -62,11 +64,11 @@ public class ConvertWebsiteToPDF { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(accepts = ToolFormat.NONE, produces = ToolFormat.PDF) @Operation( summary = "Convert a URL to a PDF", description = - "This endpoint fetches content from a URL and converts it to a PDF format." - + " Input:N/A Output:PDF Type:SISO") + "This endpoint fetches content from a URL and converts it to a PDF format.") public ResponseEntity urlToPdf(@ModelAttribute UrlToPdfRequest request) throws IOException, InterruptedException { String URL = request.getUrlInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java index 48ab474d42..1141d503aa 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java @@ -31,6 +31,9 @@ import stirling.software.SPDF.pdf.parser.TabulaTableParser; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -48,11 +51,11 @@ public class ExtractCSVController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.LARGE_WEIGHT) @CsvConversionResponse + @ToolIO(produces = ToolFormat.CSV, arity = ToolArity.SIMO) @Operation( summary = "Extracts a CSV document from a PDF", description = - "This operation takes an input PDF file and returns CSV file of whole page." - + " Input:PDF Output:CSV Type:SISO") + "This operation takes an input PDF file and returns CSV file of whole page.") public ResponseEntity pdfToCsv(@ModelAttribute PDFWithPageNums request) throws Exception { String baseName = getBaseName(request.getFileInput().getOriginalFilename()); List csvEntries = new ArrayList<>(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java index 0dbddc5e07..6b6a6a0bf0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java @@ -27,6 +27,11 @@ import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; +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; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; @@ -50,11 +55,11 @@ public class PdfVectorExportController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(accepts = ToolFormat.POSTSCRIPT, produces = ToolFormat.PDF) @Operation( summary = "Convert PostScript formats to PDF", description = - "Converts PostScript vector inputs (PS, EPS, EPSF) to PDF using Ghostscript." - + " Input:PS/EPS Output:PDF Type:SISO") + "Converts PostScript vector inputs (PS, EPS, EPSF) to PDF using Ghostscript.") public ResponseEntity convertGhostscriptInputsToPdf( @Valid @ModelAttribute PdfVectorExportRequest request) throws Exception { @@ -100,11 +105,26 @@ public class PdfVectorExportController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + // One case per non-default value of outputFormat; the base covers the default, eps. + @ToolIO( + produces = ToolFormat.IMAGE, + cases = { + @ToolIOCase( + when = @ToolIOWhen(param = "outputFormat", matches = "ps"), + produces = ToolFormat.POSTSCRIPT, + arity = ToolArity.SISO), + @ToolIOCase( + when = @ToolIOWhen(param = "outputFormat", matches = "pcl"), + produces = ToolFormat.PCL, + arity = ToolArity.SISO), + @ToolIOCase( + when = @ToolIOWhen(param = "outputFormat", matches = "xps"), + produces = ToolFormat.XPS, + arity = ToolArity.SISO) + }) @Operation( summary = "Convert PDF to vector format", - description = - "Converts PDF to Ghostscript vector formats (EPS, PS, PCL, or XPS)." - + " Input:PDF Output:VECTOR Type:SISO") + description = "Converts PDF to Ghostscript vector formats (EPS, PS, PCL, or XPS).") public ResponseEntity convertPdfToVector( @Valid @ModelAttribute PdfVectorExportRequest request) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java index 7563a09a92..9cd4bbaf1f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/filters/FilterController.java @@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.filter.PageSizeRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.FilterApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.PdfUtils; @@ -45,9 +47,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF contains set text, returns true if does", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF contains set text, returns true if does") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -79,9 +80,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF contains an image", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF contains an image") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -112,9 +112,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF is greater, less or equal to a setPageCount", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF is greater, less or equal to a setPageCount") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -146,9 +145,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF is of a certain size", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF is of a certain size") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -186,9 +184,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF is a set file size", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF is a set file size") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -217,9 +214,8 @@ public class FilterController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation", resourceWeight = ResourceWeight.SMALL_WEIGHT) - @Operation( - summary = "Checks if a PDF is of a certain rotation", - description = "Input:PDF Output:Boolean Type:SISO") + @ToolIO(produces = ToolFormat.PDF) + @Operation(summary = "Checks if a PDF is of a certain rotation") @ApiResponses({ @ApiResponse( responseCode = "200", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index 4e322b591d..dc2dd22863 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -26,6 +26,8 @@ import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.comments.AnnotationLocation; import stirling.software.common.model.api.comments.StickyNoteSpec; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfAnnotationService; import stirling.software.common.util.GeneralUtils; @@ -72,14 +74,14 @@ public class AddCommentsController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add sticky-note comments to a PDF at specified positions or anchored text", description = - "Attaches PDF Text (sticky-note) annotations to the document." - + " Each CommentSpec can either supply absolute coordinates or an" - + " `anchorText` hint; when provided, the tool locates the first matching" - + " line on the target page and anchors the icon there (falling back to" - + " the coordinates if no match). Input:PDF Output:PDF Type:SISO") + "Attaches PDF Text (sticky-note) annotations to the document. Each CommentSpec" + + " can either supply absolute coordinates or an `anchorText` hint; when provided," + + " the tool locates the first matching line on the target page and anchors the" + + " icon there (falling back to the coordinates if no match).") public ResponseEntity addComments(@ModelAttribute AddCommentsRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java index 0e272678c4..a6a3dd8c56 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -29,6 +29,8 @@ import stirling.software.SPDF.service.AttachmentServiceInterface; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -54,10 +56,10 @@ public class AttachmentController { value = "/add-attachments", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add attachments to PDF", - description = - "This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO") + description = "This endpoint adds attachments to a PDF.") public ResponseEntity addAttachments(@ModelAttribute AddAttachmentRequest request) throws Exception { MultipartFile fileInput = request.getFileInput(); @@ -143,11 +145,11 @@ public class AttachmentController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/extract-attachments", resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.ZIP) @Operation( summary = "Extract attachments from PDF", description = - "This endpoint extracts all embedded attachments from a PDF into a ZIP archive." - + " Input:PDF Output:ZIP Type:SISO") + "This endpoint extracts all embedded attachments from a PDF into a ZIP archive.") public ResponseEntity extractAttachments( @ModelAttribute ExtractAttachmentsRequest request) throws IOException { try (PDDocument document = pdfDocumentFactory.load(request, true)) { @@ -181,10 +183,10 @@ public class AttachmentController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments", resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.JSON) @Operation( summary = "List attachments in PDF", - description = - "This endpoint lists all embedded attachments in a PDF. Input:PDF Output:JSON Type:SISO") + description = "This endpoint lists all embedded attachments in a PDF.") public ResponseEntity> listAttachments(@ModelAttribute ListAttachmentsRequest request) throws IOException { try (PDDocument document = pdfDocumentFactory.load(request, true)) { @@ -200,10 +202,10 @@ public class AttachmentController { value = "/rename-attachment", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Rename attachment in PDF", - description = - "This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO") + description = "This endpoint renames an embedded attachment in a PDF.") public ResponseEntity renameAttachment( @ModelAttribute RenameAttachmentRequest request) throws Exception { MultipartFile fileInput = request.getFileInput(); @@ -236,10 +238,10 @@ public class AttachmentController { value = "/delete-attachment", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Delete attachment from PDF", - description = - "This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO") + description = "This endpoint deletes an embedded attachment from a PDF.") public ResponseEntity deleteAttachment( @ModelAttribute DeleteAttachmentRequest request) throws Exception { MultipartFile fileInput = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java index 405e45d9f2..60333191d3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRenameController.java @@ -24,6 +24,8 @@ import stirling.software.SPDF.model.api.misc.ExtractHeaderRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.TempFileManager; @@ -44,11 +46,12 @@ public class AutoRenameController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/auto-rename", resourceWeight = ResourceWeight.SMALL_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Extract header from PDF file", description = "This endpoint accepts a PDF file and attempts to extract its title or header" - + " based on heuristics. Input:PDF Output:PDF Type:SISO") + + " based on heuristics.") public ResponseEntity extractHeader(@ModelAttribute ExtractHeaderRequest request) throws Exception { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRotateController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRotateController.java index 21a5885c7d..85d60b02be 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRotateController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoRotateController.java @@ -43,6 +43,11 @@ import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -76,6 +81,13 @@ public class AutoRotateController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/auto-rotate-pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO( + produces = ToolFormat.PDF, + cases = + @ToolIOCase( + when = @ToolIOWhen(param = "dryRun", matches = "true"), + produces = ToolFormat.JSON, + arity = ToolArity.SISO)) @Operation( summary = "Detect and fix the orientation of every page", description = @@ -83,8 +95,7 @@ public class AutoRotateController { + " for scanned pages) and sets the page rotation so the content" + " displays upright. With dryRun=true, returns a JSON per-page report" + " instead of the PDF. With pageRotations set, applies the given" - + " corrections without running detection." - + " Input:PDF Output:PDF Type:SISO") + + " corrections without running detection.") public ResponseEntity autoRotatePdf(@Valid @ModelAttribute AutoRotatePdfRequest request) throws IOException, InterruptedException { String mode = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java index ad5776b3cd..39ba6e54f5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java @@ -41,6 +41,9 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -274,13 +277,13 @@ public class AutoSplitPdfController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Auto split PDF pages into separate documents", description = "This endpoint accepts a PDF file, scans each page for a specific QR code, and" - + " splits the document at the QR code boundaries. The output is a zip" - + " file containing each separate PDF document. Input:PDF Output:ZIP-PDF" - + " Type:SISO") + + " splits the document at the QR code boundaries. The output is a zip file" + + " containing each separate PDF document.") public ResponseEntity autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java index 9b702e9c58..8105f309bc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java @@ -33,6 +33,9 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ExceptionUtils; @@ -86,12 +89,12 @@ public class BlankPageController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-blanks", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PDF, arity = ToolArity.SIMO) @Operation( summary = "Remove blank pages from a PDF file", description = "This endpoint removes blank pages from a given PDF file. Users can specify the" - + " threshold and white percentage to tune the detection of blank pages." - + " Input:PDF Output:PDF Type:SISO") + + " threshold and white percentage to tune the detection of blank pages.") public ResponseEntity removeBlankPages( @ModelAttribute RemoveBlankPagesRequest request) throws IOException, InterruptedException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index 8bae8adbf5..fcbfcea62e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -50,6 +50,8 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.LineArtConversionService; import stirling.software.common.util.ExceptionUtils; @@ -927,11 +929,12 @@ public class CompressController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Optimize PDF file", description = "This endpoint accepts a PDF file and optimizes it based on the provided" - + " parameters. Input:PDF Output:PDF Type:SISO") + + " parameters.") public ResponseEntity optimizePdf(@ModelAttribute OptimizePdfRequest request) throws Exception { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java index a867937c38..2f475faa9a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/DecompressPdfController.java @@ -24,6 +24,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -43,6 +45,7 @@ public class DecompressPdfController { value = "/decompress-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Decompress PDF streams", description = "Fully decompresses all PDF streams including text content") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java index 2284abfbf6..daa8f35121 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java @@ -34,6 +34,9 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.CheckProgramInstall; @@ -60,13 +63,13 @@ public class ExtractImageScansController { value = "/extract-image-scans", resourceWeight = ResourceWeight.LARGE_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.IMAGE, arity = ToolArity.SIMO) @Operation( summary = "Extract image scans from an input file", description = "This endpoint extracts image scans from a given file based on certain" + " parameters. Users can specify angle threshold, tolerance, minimum area," - + " minimum contour area, and border size. Input:PDF Output:IMAGE/ZIP" - + " Type:SIMO") + + " minimum contour area, and border size.") public ResponseEntity extractImageScans( @ModelAttribute ExtractImageScansRequest request) throws IOException, InterruptedException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java index 8faf93ec2d..3dfe1d6ba2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java @@ -35,6 +35,9 @@ import stirling.software.SPDF.model.api.PDFExtractImagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +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.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -55,12 +58,12 @@ public class ExtractImagesController { value = "/extract-images", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MultiFileResponse + @ToolIO(produces = ToolFormat.IMAGE, arity = ToolArity.SIMO) @Operation( summary = "Extract images from a PDF file", description = "This endpoint extracts images from a given PDF file and returns them in a zip" - + " file. Users can specify the output image format. Input:PDF" - + " Output:IMAGE/ZIP Type:SIMO") + + " file. Users can specify the output image format.") public ResponseEntity extractImages(@ModelAttribute PDFExtractImagesRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java index 21ca7b2d51..9bbbeffccb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java @@ -29,6 +29,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ExceptionUtils; @@ -48,11 +50,12 @@ public class FlattenController { value = "/flatten", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Flatten PDF form fields or full page", description = "Flattening just PDF form fields or converting each page to images to make text" - + " unselectable. Input:PDF, Output:PDF. Type:SISO") + + " unselectable.") public ResponseEntity flatten(@ModelAttribute FlattenRequest request) throws Exception { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java index ca68547003..b51bfdc3fb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MetadataController.java @@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.misc.MetadataRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.util.GeneralUtils; @@ -62,12 +64,12 @@ public class MetadataController { value = "/update-metadata", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Update metadata of a PDF file", description = "This endpoint allows you to update the metadata of a given PDF file. You can" - + " add, modify, or delete standard and custom metadata fields. Input:PDF" - + " Output:PDF Type:SISO") + + " add, modify, or delete standard and custom metadata fields.") public ResponseEntity metadata(@ModelAttribute MetadataRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 4660d5f128..4803184a33 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -41,6 +41,11 @@ import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -88,13 +93,20 @@ public class OCRController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf", resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO( + produces = ToolFormat.PDF, + cases = + @ToolIOCase( + when = @ToolIOWhen(param = "sidecar", matches = "true"), + produces = ToolFormat.ZIP, + arity = ToolArity.SISO)) @Operation( summary = "Process a PDF file with OCR", description = - "This endpoint processes a PDF file using OCR (Optical Character Recognition). Users can" - + " specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType," - + " and removeImagesAfter options. Uses OCRmyPDF if available, falls back to" - + " Tesseract. Input:PDF Output:PDF Type:SI-Conditional") + "This endpoint processes a PDF file using OCR (Optical Character Recognition)." + + " Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType," + + " ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if available," + + " falls back to Tesseract.") public ResponseEntity processPdfWithOCR( @ModelAttribute ProcessPdfWithOcrRequest request) throws IOException, InterruptedException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java index 557c77c76e..4f7c335a23 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java @@ -23,6 +23,8 @@ import stirling.software.SPDF.utils.SvgOverlayUtil; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.SvgSanitizer; @@ -43,14 +45,14 @@ public class OverlayImageController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-image", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Overlay image onto a PDF file", description = - "This endpoint overlays an image onto a PDF file at the specified coordinates. " - + "Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). " - + "SVG files are rendered as vector graphics for crisp output at any resolution. " - + "The image can be overlaid on every page of the PDF if specified. " - + "Input:PDF/IMAGE/SVG Output:PDF Type:SISO") + "This endpoint overlays an image onto a PDF file at the specified coordinates." + + " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). SVG" + + " files are rendered as vector graphics for crisp output at any resolution. The" + + " image can be overlaid on every page of the PDF if specified.") public ResponseEntity overlayImage(@ModelAttribute OverlayImageRequest request) { MultipartFile pdfFile = request.getFileInput(); MultipartFile imageFile = request.getImageFile(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java index e000d9ff3a..bcced01ba9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PageNumbersController.java @@ -27,6 +27,8 @@ import stirling.software.SPDF.model.api.misc.AddPageNumbersRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; @@ -45,11 +47,10 @@ public class PageNumbersController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add page numbers to a PDF document", - description = - "This operation takes an input PDF file and adds page numbers to it. Input:PDF" - + " Output:PDF Type:SISO") + description = "This operation takes an input PDF file and adds page numbers to it.") public ResponseEntity addPageNumbers(@ModelAttribute AddPageNumbersRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index e369bbf102..82184b68ba 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -46,8 +46,8 @@ public class PrintFileController { // @Operation( // summary = "Prints PDF/Image file to a set printer", // description = - // "Input of PDF or Image along with a printer name/URL/IP to match against to - // send it to (Fire and forget) Input:Any Output:N/A Type:SISO") + // "Input of PDF or Image along with a printer name/URL/IP to match against + // to send it to (Fire and forget)") public ResponseEntity printFile(@ModelAttribute PrintFileRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java index ce4ca94239..44703af863 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RemoveImagesController.java @@ -27,6 +27,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -46,11 +48,12 @@ public class RemoveImagesController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Remove images from PDF", description = "This endpoint removes all embedded images from a PDF file and returns the" - + " modified document. Input:PDF Output:PDF Type:SISO") + + " modified document.") public ResponseEntity removeImages(@ModelAttribute PDFFile request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index 076e977b4c..7c74c85d09 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -21,6 +21,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -52,12 +54,13 @@ public class RepairController { value = "/repair", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Repair a PDF file", description = - "This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf (fallback), or PDFBox (if no external tools available). The PDF is" - + " first saved to a temporary location, repaired, read back, and then" - + " returned as a response. Input:PDF Output:PDF Type:SISO") + "This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf" + + " (fallback), or PDFBox (if no external tools available). The PDF is first saved" + + " to a temporary location, repaired, read back, and then returned as a response.") public ResponseEntity repairPdf(@ModelAttribute PDFFile file) throws IOException, InterruptedException { MultipartFile inputFile = file.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java index 7c5115e361..3a5f0259f5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ReplaceAndInvertColorController.java @@ -20,6 +20,8 @@ import stirling.software.SPDF.service.misc.ReplaceAndInvertColorService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @@ -36,11 +38,13 @@ public class ReplaceAndInvertColorController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/replace-invert-pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Replace-Invert Color PDF", description = - "This endpoint accepts a PDF file and provides options to invert all colors, replace" - + " text and background colors, or convert to CMYK color space for printing. Input:PDF Output:PDF Type:SISO") + "This endpoint accepts a PDF file and provides options to invert all colors," + + " replace text and background colors, or convert to CMYK color space for" + + " printing.") public ResponseEntity replaceAndInvertColor( @ModelAttribute ReplaceAndInvertColorRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java index 445009000f..eb6b74fd58 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java @@ -48,6 +48,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ExceptionUtils; @@ -564,10 +566,12 @@ public class ScannerEffectController { value = "/scanner-effect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Apply scanner effect to PDF", description = - "Applies various effects to simulate a scanned document, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO") + "Applies various effects to simulate a scanned document, including rotation," + + " noise, and edge softening.") public ResponseEntity scannerEffect( @Valid @ModelAttribute ScannerEffectRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java index 9897100a7d..fcba1ea718 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ShowJavascript.java @@ -23,6 +23,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @@ -40,9 +42,10 @@ public class ShowJavascript { value = "/show-javascript", resourceWeight = ResourceWeight.SMALL_WEIGHT) @JavaScriptResponse + @ToolIO(produces = ToolFormat.JAVASCRIPT) @Operation( summary = "Grabs all JS from a PDF and returns a single JS file with all code", - description = "desc. Input:PDF Output:JS Type:SISO") + description = "desc.") public ResponseEntity extractHeader(@ModelAttribute PDFFile file) throws Exception { MultipartFile inputFile = file.getFileInput(); StringBuilder script = new StringBuilder(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index 9b9f0213cd..0cd485d7d1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -47,6 +47,8 @@ import stirling.software.SPDF.model.api.misc.AddStampRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -91,12 +93,12 @@ public class StampController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add stamp to a PDF file", description = "This endpoint adds a stamp to a given PDF file. Users can specify the stamp" - + " type (text or image), rotation, opacity, width spacer, and height" - + " spacer. Input:PDF Output:PDF Type:SISO") + + " type (text or image), rotation, opacity, width spacer, and height spacer.") public ResponseEntity addStamp(@ModelAttribute AddStampRequest request) throws IOException, Exception { MultipartFile pdfFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java index 246626b38b..3dcabd43f7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsController.java @@ -25,6 +25,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.RegexPatternUtils; @@ -48,11 +50,10 @@ public class UnlockPDFFormsController { value = "/unlock-pdf-forms", resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Remove read-only property from form fields", - description = - "Removing read-only property from form fields making them fillable" - + "Input:PDF, Output:PDF. Type:SISO") + description = "Removing read-only property from form fields making them fillable") public ResponseEntity unlockPDFForms(@ModelAttribute PDFFile file) { try (PDDocument document = pdfDocumentFactory.load(file)) { PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java index 40102fb742..b624434502 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java @@ -59,8 +59,7 @@ public class PipelineController { summary = "Execute automated PDF processing pipeline", description = "This endpoint processes multiple PDF files through a configurable pipeline of operations. " - + "Users provide files and a JSON configuration defining the sequence of operations to perform. " - + "Input:PDF Output:PDF/ZIP Type:MIMO") + + "Users provide files and a JSON configuration defining the sequence of operations to perform.") public ResponseEntity handleData(@ModelAttribute HandleDataRequest request) throws DatabindException, JacksonException { MultipartFile[] files = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java index 79223ddd38..627b1461f6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java @@ -35,6 +35,7 @@ import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.service.ApiDocService; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.service.PostHogService; +import stirling.software.common.service.ToolMetadataService; import stirling.software.common.util.FileReadinessChecker; import tools.jackson.databind.ObjectMapper; @@ -48,6 +49,7 @@ public class PipelineDirectoryProcessor { private final ObjectMapper objectMapper; private final ApiDocService apiDocService; + private final ToolMetadataService toolMetadataService; private final PipelineProcessor processor; private final PostHogService postHogService; private final FileReadinessChecker fileReadinessChecker; @@ -61,12 +63,14 @@ public class PipelineDirectoryProcessor { public PipelineDirectoryProcessor( ObjectMapper objectMapper, ApiDocService apiDocService, + ToolMetadataService toolMetadataService, PipelineProcessor processor, PostHogService postHogService, FileReadinessChecker fileReadinessChecker, RuntimePathConfig runtimePathConfig) { this.objectMapper = objectMapper; this.apiDocService = apiDocService; + this.toolMetadataService = toolMetadataService; this.processor = processor; this.postHogService = postHogService; this.fileReadinessChecker = fileReadinessChecker; @@ -229,7 +233,7 @@ public class PipelineDirectoryProcessor { throws IOException { List inputExtensions = - apiDocService.getExtensionTypes(false, operation.getOperation()); + toolMetadataService.getExtensionTypes(false, operation.getOperation()); log.info( "Allowed extensions for operation {}: {}", operation.getOperation(), diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index c4f1c76f5e..08684dd5fe 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -30,6 +30,7 @@ import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.service.ApiDocService; import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.ToolMetadataService; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.ZipExtractionUtils; @@ -39,15 +40,19 @@ public class PipelineProcessor { private final ApiDocService apiDocService; + private final ToolMetadataService toolMetadataService; + private final InternalApiClient internalApiClient; private final TempFileManager tempFileManager; public PipelineProcessor( ApiDocService apiDocService, + ToolMetadataService toolMetadataService, InternalApiClient internalApiClient, TempFileManager tempFileManager) { this.apiDocService = apiDocService; + this.toolMetadataService = toolMetadataService; this.internalApiClient = internalApiClient; this.tempFileManager = tempFileManager; } @@ -92,13 +97,13 @@ public class PipelineProcessor { boolean filtersApplied = false; for (PipelineOperation pipelineOperation : config.getOperations()) { String operation = pipelineOperation.getOperation(); - boolean isMultiInputOperation = apiDocService.isMultiInput(operation); + boolean isMultiInputOperation = toolMetadataService.isMultiInput(operation); log.info( "Running operation: {} isMultiInputOperation {}", operation, isMultiInputOperation); Map parameters = pipelineOperation.getParameters(); - List inputFileTypes = apiDocService.getExtensionTypes(false, operation); + List inputFileTypes = toolMetadataService.getExtensionTypes(false, operation); if (inputFileTypes == null) { inputFileTypes = new ArrayList<>(List.of("ALL")); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 7ad69ebc5b..ffadb8fe1a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -79,6 +79,8 @@ import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; import stirling.software.SPDF.service.HardwareKeyStoreService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.common.util.ExceptionUtils; @@ -170,12 +172,13 @@ public class CertSignController { value = "/cert-sign", resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Sign PDF with a Digital Certificate", description = "This endpoint accepts a PDF file, a digital certificate and related" + " information to sign the PDF. It then returns the digitally signed PDF" - + " file. Input:PDF Output:PDF Type:SISO") + + " file.") public ResponseEntity signPDFWithCert( @ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest) throws Exception { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index a66b168163..d29480fd3c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -58,6 +58,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; @@ -1095,10 +1097,10 @@ public class GetInfoOnPDF { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.JSON) @Operation( summary = "Get comprehensive PDF information", - description = - "Extracts all available information from a PDF file. Input:PDF Output:JSON Type:SISO") + description = "Extracts all available information from a PDF file.") public ResponseEntity getPdfInfo(@ModelAttribute PDFFile request) throws IOException { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index 946eee0359..690c82ca8f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -21,6 +21,11 @@ import stirling.software.SPDF.model.api.security.PDFPasswordRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; +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; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -39,11 +44,14 @@ public class PasswordController { value = "/remove-password", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO( + accepts = {ToolFormat.PDF, ToolFormat.PDF_ENCRYPTED}, + produces = ToolFormat.PDF) @Operation( summary = "Remove password from a PDF file", description = "This endpoint removes the password from a protected PDF file. Users need to" - + " provide the existing password. Input:PDF Output:PDF Type:SISO") + + " provide the existing password.") public ResponseEntity removePassword(@ModelAttribute PDFPasswordRequest request) throws IOException { MultipartFile fileInput = request.getFileInput(); @@ -71,12 +79,21 @@ public class PasswordController { value = "/add-password", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO( + produces = ToolFormat.PDF_ENCRYPTED, + cases = + @ToolIOCase( + when = { + @ToolIOWhen(param = "password", matches = ""), + @ToolIOWhen(param = "ownerPassword", matches = "") + }, + produces = ToolFormat.PDF, + arity = ToolArity.SISO)) @Operation( summary = "Add password to a PDF file", description = "This endpoint adds password protection to a PDF file. Users can specify a set" - + " of permissions that should be applied to the file. Input:PDF" - + " Output:PDF") + + " of permissions that should be applied to the file.") public ResponseEntity addPassword(@ModelAttribute AddPasswordRequest request) throws IOException { MultipartFile fileInput = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index ece149a820..c280264838 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -33,6 +33,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.security.RedactionArea; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.PdfUtils; @@ -82,13 +84,14 @@ public class RedactController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( operationId = "redactPdfManual", summary = "Redacts areas and pages in a PDF document", description = - "This endpoint redacts content from a PDF file based on manually specified areas. " - + "Users can specify areas to redact and optionally convert the PDF to an image. " - + "Input:PDF Output:PDF Type:SISO") + "This endpoint redacts content from a PDF file based on manually specified" + + " areas. Users can specify areas to redact and optionally convert the PDF to an" + + " image.") public ResponseEntity redactPDF(@ModelAttribute ManualRedactPdfRequest request) throws IOException { @@ -128,13 +131,14 @@ public class RedactController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Redact PDF automatically", operationId = "redactPdfAuto", description = - "This endpoint automatically redacts text from a PDF file based on specified patterns. " - + "Users can provide text patterns to redact, with options for regex and whole word matching. " - + "Input:PDF Output:PDF Type:SISO") + "This endpoint automatically redacts text from a PDF file based on specified" + + " patterns. Users can provide text patterns to redact, with options for regex" + + " and whole word matching.") public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) { String rawListOfText = request.getListOfText(); boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); @@ -272,13 +276,13 @@ public class RedactController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( operationId = "redactExecute", summary = "Execute a unified redaction plan on a PDF", description = - "Unified redaction endpoint that accepts exact strings, regex patterns, and " - + "page numbers in a single request. Supports execution strategy hints. " - + "Input:PDF Output:PDF Type:SISO") + "Unified redaction endpoint that accepts exact strings, regex patterns, and" + + " page numbers in a single request. Supports execution strategy hints.") public ResponseEntity executeRedaction(@ModelAttribute RedactExecuteRequest request) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java index 26f4a09e89..bb1e0e48d5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RemoveCertSignController.java @@ -22,6 +22,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFileManager; @@ -39,11 +41,12 @@ public class RemoveCertSignController { value = "/remove-cert-sign", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Remove digital signature from PDF", description = "This endpoint accepts a PDF file and returns the PDF file without the digital" - + " signature. Input:PDF, Output:PDF Type:SISO") + + " signature.") public ResponseEntity removeCertSignPDF(@ModelAttribute PDFFile request) throws Exception { MultipartFile pdf = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java index 15c768349d..4b3fd497a3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/SanitizeController.java @@ -40,6 +40,8 @@ import stirling.software.SPDF.model.api.security.SanitizePdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFileManager; @@ -58,11 +60,12 @@ public class SanitizeController { value = "/sanitize-pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Sanitize a PDF file", description = "This endpoint processes a PDF file and removes specific elements based on the" - + " provided options. Input:PDF Output:PDF Type:SISO") + + " provided options.") public ResponseEntity sanitizePDF(@ModelAttribute SanitizePdfRequest request) throws IOException { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java index 9110131f4f..2329ca2484 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java @@ -45,6 +45,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; @@ -84,13 +86,13 @@ public class TimestampController { value = "/timestamp-pdf", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add RFC 3161 document timestamp to a PDF", description = "Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161" - + " document timestamp into the PDF. Only a SHA-256 hash of the" - + " document is sent to the TSA — the PDF itself never leaves the" - + " server. Input:PDF Output:PDF Type:SISO") + + " document timestamp into the PDF. Only a SHA-256 hash of the document is sent" + + " to the TSA - the PDF itself never leaves the server.") public ResponseEntity timestampPdf(@ModelAttribute TimestampPdfRequest request) throws Exception { MultipartFile inputFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index f9eeeb6415..c807a68bc8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -46,6 +46,8 @@ import stirling.software.SPDF.service.CertificateValidationService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; @@ -73,12 +75,12 @@ public class ValidateSignatureController { } @JsonDataResponse + @ToolIO(produces = ToolFormat.JSON) @Operation( summary = "Validate PDF Digital Signature", description = - "Validates the digital signatures in a PDF file using PKIX path building" - + " and time-of-signing semantics. Supports custom trust anchors." - + " Input:PDF Output:JSON Type:SISO") + "Validates the digital signatures in a PDF file using PKIX path building and" + + " time-of-signing semantics. Supports custom trust anchors.") @AutoJobPostMapping( value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java index 505eeb2c62..4226005c8e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/VerifyPDFController.java @@ -22,6 +22,8 @@ import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.util.ExceptionUtils; @SecurityApi @@ -31,13 +33,13 @@ public class VerifyPDFController { private final VeraPDFService veraPDFService; + @ToolIO(produces = ToolFormat.JSON) @Operation( summary = "Verify PDF Standards Compliance", description = - "Validates PDF files against the standards declared in their metadata. " - + "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards " - + "from the document's XMP metadata and validates compliance. " - + "Input:PDF Output:JSON Type:SISO") + "Validates PDF files against the standards declared in their metadata." + + " Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards from the" + + " document's XMP metadata and validates compliance.") @AutoJobPostMapping( value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 38364e541e..0bcc3a6439 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -43,6 +43,8 @@ import stirling.software.SPDF.model.api.security.AddWatermarkRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.PdfUtils; @@ -74,12 +76,13 @@ public class WatermarkController { value = "/add-watermark", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @StandardPdfResponse + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Add watermark to a PDF file", description = "This endpoint adds a watermark to a given PDF file. Users can specify the" - + " watermark type (text or image), rotation, opacity, width spacer, and" - + " height spacer. Input:PDF Output:PDF Type:SISO") + + " watermark type (text or image), rotation, opacity, width spacer, and height" + + " spacer.") public ResponseEntity addWatermark(@Valid @ModelAttribute AddWatermarkRequest request) throws IOException, Exception { MultipartFile pdfFile = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java index 42ebd51ab3..45f093148b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java @@ -17,6 +17,8 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.pdf.PdfMarkdownConverter; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; @@ -34,10 +36,10 @@ public class ConvertPDFToMarkdown { value = "/pdf/markdown", resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MarkdownConversionResponse + @ToolIO(produces = ToolFormat.MARKDOWN) @Operation( summary = "Convert PDF to Markdown", - description = - "This endpoint converts a PDF file to Markdown format. Input:PDF Output:Markdown Type:SISO") + description = "This endpoint converts a PDF file to Markdown format.") public ResponseEntity processPdfToMarkdown(@ModelAttribute PDFFile file) throws Exception { MultipartFile inputFile = file.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java b/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java index 3a8d418aa6..35f194f5d8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java @@ -1,12 +1,7 @@ package stirling.software.SPDF.service; -import java.util.Arrays; import java.util.HashMap; -import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpEntity; @@ -24,28 +19,26 @@ import stirling.software.SPDF.SPDFApplication; import stirling.software.SPDF.model.ApiEndpoint; import stirling.software.common.model.enumeration.Role; import stirling.software.common.service.UserServiceInterface; -import stirling.software.common.util.RegexPatternUtils; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +/** + * Validates a request's parameters against the OpenAPI spec. + * + *

Input and output types are no longer read from here: they are declared with {@code @ToolIO} + * and served by {@code ToolIORegistry}, which reads the annotations directly rather than parsing + * them back out of the description prose. + */ @Service @Slf4j -public class ApiDocService implements stirling.software.common.service.ToolMetadataService { - - // Matches a bare "Output:ZIP" declaration (i.e. ZIP is not followed by "-" or "/"). - // Bare ZIP means the archive itself is the deliverable (e.g. get-attachments), so it - // should not be auto-unpacked. Wrapper forms like Output:ZIP-PDF or Output:IMAGE/ZIP - // use ZIP as transport for multiple typed results and are safe to unpack. - private static final Pattern BARE_ZIP_OUTPUT = - Pattern.compile("Output\\s*:\\s*ZIP(?![-/])", Pattern.CASE_INSENSITIVE); +public class ApiDocService { private final Map apiDocumentation = new HashMap<>(); private final ServletContext servletContext; private final UserServiceInterface userService; private final ObjectMapper objectMapper; - Map> outputToFileTypes = new HashMap<>(); JsonNode apiDocsJsonRootNode; public ApiDocService( @@ -63,52 +56,6 @@ public class ApiDocService implements stirling.software.common.service.ToolMetad return "http://localhost:" + port + contextPath + "/v1/api-docs"; } - @Override - public List getExtensionTypes(boolean output, String operationName) { - if (outputToFileTypes.isEmpty()) { - outputToFileTypes.put("PDF", List.of("pdf")); - outputToFileTypes.put( - "IMAGE", - Arrays.asList( - "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "psd", - "ai", "eps")); - outputToFileTypes.put( - "ZIP", - Arrays.asList("zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "lzma", "z")); - outputToFileTypes.put("WORD", Arrays.asList("doc", "docx", "odt", "rtf")); - outputToFileTypes.put("CSV", List.of("csv")); - outputToFileTypes.put("JS", Arrays.asList("js", "jsx")); - outputToFileTypes.put("HTML", Arrays.asList("html", "htm", "xhtml")); - outputToFileTypes.put("JSON", List.of("json")); - outputToFileTypes.put("TXT", Arrays.asList("txt", "text", "md", "markdown")); - outputToFileTypes.put("PPT", Arrays.asList("ppt", "pptx", "odp")); - outputToFileTypes.put("XML", Arrays.asList("xml", "xsd", "xsl")); - outputToFileTypes.put( - "BOOK", Arrays.asList("epub", "mobi", "azw3", "fb2", "txt", "docx")); - // type. - } - if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) { - loadApiDocumentation(); - } - if (!apiDocumentation.containsKey(operationName)) { - return null; - } - ApiEndpoint endpoint = apiDocumentation.get(operationName); - String description = endpoint.getDescription(); - Matcher matcher = - (output - ? RegexPatternUtils.getInstance().getApiDocOutputTypePattern() - : RegexPatternUtils.getInstance().getApiDocInputTypePattern()) - .matcher(description); - while (matcher.find()) { - String type = matcher.group(1).toUpperCase(Locale.ROOT); - if (outputToFileTypes.containsKey(type)) { - return outputToFileTypes.get(type); - } - } - return null; - } - private String getApiKeyForUser() { if (userService == null) return ""; return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId()); @@ -157,55 +104,4 @@ public class ApiDocService implements stirling.software.common.service.ToolMetad ApiEndpoint endpoint = apiDocumentation.get(operationName); return endpoint.areParametersValid(parameters); } - - @Override - public boolean isMultiInput(String operationName) { - if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) { - loadApiDocumentation(); - } - if (!apiDocumentation.containsKey(operationName)) { - return false; - } - ApiEndpoint endpoint = apiDocumentation.get(operationName); - String description = endpoint.getDescription(); - Matcher matcher = - RegexPatternUtils.getInstance().getApiDocTypePattern().matcher(description); - if (matcher.find()) { - String type = matcher.group(1); - return type.startsWith("MI"); - } - return false; - } - - @Override - public boolean shouldUnpackZipResponse(String operationName) { - if (apiDocsJsonRootNode == null || apiDocumentation.isEmpty()) { - loadApiDocumentation(); - } - if (!apiDocumentation.containsKey(operationName)) { - return false; - } - ApiEndpoint endpoint = apiDocumentation.get(operationName); - String description = endpoint.getDescription(); - Matcher typeMatcher = - RegexPatternUtils.getInstance().getApiDocTypePattern().matcher(description); - if (typeMatcher.find()) { - String type = typeMatcher.group(1); - // Multi-output endpoints (SIMO/MIMO) return a ZIP of their outputs. - if (type.endsWith("MO")) { - return true; - } - } - Matcher outputMatcher = - RegexPatternUtils.getInstance().getApiDocOutputTypePattern().matcher(description); - if (outputMatcher.find()) { - String output = outputMatcher.group(1).toUpperCase(Locale.ROOT); - if (output.startsWith("ZIP")) { - // Bare "Output:ZIP" is a single-archive deliverable, not a transport. - return !BARE_ZIP_OUTPUT.matcher(description).find(); - } - } - return false; - } } -// Model class for API Endpoint diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java new file mode 100644 index 0000000000..120bee6a2b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java @@ -0,0 +1,342 @@ +package stirling.software.SPDF.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.model.tool.ToolIOSpec; + +/** + * Every document-transforming endpoint must declare its I/O, or it becomes a hole in the + * compatibility graph: an undeclared step reads as "cannot tell" and nothing past it is checked. + * + *

The rest pins the declarations that were corrected on the way out of the description prose, + * where several disagreed with what the endpoint actually does. + */ +class ToolIODeclarationCoverageTest { + + /** The namespaces whose endpoints act on documents. */ + private static final List TOOL_NAMESPACES = + List.of( + "/api/v1/general/", + "/api/v1/misc/", + "/api/v1/security/", + "/api/v1/convert/", + "/api/v1/filter/", + "/api/v1/integration/"); + + /** + * Not document transforms, so nothing to declare. A path exempts everything nested under it. + * Keep it short: if you are adding a real tool, annotate it instead. + */ + private static final List EXEMPT = + List.of( + // Interactive editing sessions: page fragments and cache handles, not + // documents. + "/api/v1/convert/pdf/text-editor", + "/api/v1/convert/text-editor/pdf", + // Signing sessions, certificate checks and hardware token enumeration; the + // signing tool itself is /api/v1/security/cert-sign, which is declared. + "/api/v1/security/cert-sign/sessions", + "/api/v1/security/cert-sign/validate-certificate", + "/api/v1/security/cert-sign/hardware"); + + private record Scan(Set required, Map declared) {} + + private static final Scan SCAN = scan(); + private static final Map DECLARED = SCAN.declared(); + + @Test + void everyToolEndpointDeclaresItsIo() { + List missing = + SCAN.required().stream() + .filter(path -> !DECLARED.containsKey(path)) + .sorted() + .toList(); + assertTrue( + missing.isEmpty(), + () -> + "These tool endpoints are missing a @ToolIO declaration. Add one so a" + + " pipeline containing them can be checked before it runs, or" + + " exempt them in EXEMPT with a reason:\n " + + String.join("\n ", missing)); + } + + @ParameterizedTest + @ValueSource( + strings = { + // Splitters emit a set of documents; the archive is only transport. + "/api/v1/general/split-pages", + "/api/v1/general/split-pdf-by-sections", + "/api/v1/general/split-pdf-by-chapters", + "/api/v1/general/split-by-size-or-count", + "/api/v1/general/split-for-poster-print", + "/api/v1/misc/auto-split-pdf", + "/api/v1/misc/extract-images", + "/api/v1/misc/extract-image-scans", + // Corrected: both were declared single-output but return an archive of results. + "/api/v1/misc/remove-blanks", + "/api/v1/convert/pdf/csv" + }) + void multiResultEndpointsAreUnpacked(String path) { + assertTrue(spec(path).resolveOutput().arity().isMultiOutput(), path); + } + + @ParameterizedTest + @ValueSource( + strings = { + // The archive itself is the deliverable. + "/api/v1/misc/extract-attachments", + // A page of HTML plus the assets it references; unpacking scatters them. + "/api/v1/convert/pdf/html", + "/api/v1/general/rotate-pdf", + "/api/v1/general/merge-pdfs", + // Corrected: was declared MIMO, but it returns one overlaid document. + "/api/v1/general/overlay-pdfs", + // Sits under the exempted cert-sign session paths; pinned so it stays declared. + "/api/v1/security/cert-sign" + }) + void singleResultEndpointsAreNotUnpacked(String path) { + assertFalse(spec(path).resolveOutput().arity().isMultiOutput(), path); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/api/v1/general/merge-pdfs", + "/api/v1/general/overlay-pdfs", + "/api/v1/convert/img/pdf" + }) + void multiInputEndpointsTakeEveryFileAtOnce(String path) { + assertTrue(spec(path).arity().isMultiInput(), path); + } + + @ParameterizedTest + @ValueSource( + strings = { + // Corrected: the multiple files are the attachments, a secondary field. The + // primary stream is one document in, one out, so these must not fan in. + "/api/v1/misc/add-attachments", + "/api/v1/misc/delete-attachment", + "/api/v1/misc/rename-attachment" + }) + void attachmentEditsActOnOneDocument(String path) { + assertFalse(spec(path).arity().isMultiInput(), path); + } + + @ParameterizedTest + @ValueSource( + strings = { + // Corrected: these declared Output:Boolean, which would have reported every chain + // after a filter as broken. A filter returns the document, or 204 to drop it. + "/api/v1/filter/filter-contains-text", + "/api/v1/filter/filter-contains-image", + "/api/v1/filter/filter-file-size", + "/api/v1/filter/filter-page-count", + "/api/v1/filter/filter-page-rotation", + "/api/v1/filter/filter-page-size" + }) + void filtersPassTheDocumentThrough(String path) { + ToolIOSpec spec = spec(path); + assertTrue(spec.acceptsFormat(ToolFormat.PDF), path); + assertEquals(ToolFormat.PDF, spec.resolveOutput().format(), path); + } + + @Test + void addPasswordProducesAnEncryptedDocument() { + ToolIOSpec spec = spec("/api/v1/security/add-password"); + assertEquals( + ToolFormat.PDF_ENCRYPTED, + spec.resolveOutput(Map.of("password", "hunter2", "ownerPassword", "")).format()); + } + + @Test + void addPasswordWithNoPasswordsOnlySetsPermissions() { + // The controller returns the document unencrypted when both passwords are absent, so a + // permissions-only call must not close the chain to every downstream tool. + ToolIOSpec spec = spec("/api/v1/security/add-password"); + assertEquals( + ToolFormat.PDF, + spec.resolveOutput(Map.of("password", "", "ownerPassword", "")).format()); + } + + @Test + void pdfToTextProducesTextOnlyForTheTxtBranch() { + // The endpoint serves both formats, and RTF is a word-processor format: declaring the whole + // endpoint as TEXT would pass RTF into text tools and reject it from the Word tools that + // can actually open it. + ToolIOSpec spec = spec("/api/v1/convert/pdf/text"); + assertEquals(ToolFormat.TEXT, spec.resolveOutput(Map.of("outputFormat", "txt")).format()); + assertEquals(ToolFormat.WORD, spec.resolveOutput(Map.of("outputFormat", "rtf")).format()); + } + + @Test + void theHtmlRoundTripIsAValidChain() { + // convert/pdf/html emits a ZIP of the page plus its assets, and convert/html/pdf takes one + // back. Declaring only HTML would reject the round trip the two endpoints exist to support. + assertEquals(ToolFormat.ZIP, spec("/api/v1/convert/pdf/html").resolveOutput().format()); + assertTrue(spec("/api/v1/convert/html/pdf").acceptsFormat(ToolFormat.ZIP)); + assertTrue(spec("/api/v1/convert/html/pdf").acceptsFormat(ToolFormat.HTML)); + } + + @Test + void markdownToPdfTakesAZipOfMarkdownAndImages() { + assertTrue(spec("/api/v1/convert/markdown/pdf").acceptsFormat(ToolFormat.ZIP)); + assertTrue(spec("/api/v1/convert/markdown/pdf").acceptsFormat(ToolFormat.MARKDOWN)); + } + + @Test + void vectorExportDeclaresEveryOutputFormat() { + // Every value the request permits must resolve to what it actually emits. "eps" is the + // default and is an image extension; the other three are not, and would otherwise fall + // through to IMAGE and pass a chain that fails at run time. + ToolIOSpec spec = spec("/api/v1/convert/pdf/vector"); + assertEquals(ToolFormat.IMAGE, spec.resolveOutput(Map.of("outputFormat", "eps")).format()); + assertEquals( + ToolFormat.POSTSCRIPT, spec.resolveOutput(Map.of("outputFormat", "ps")).format()); + assertEquals(ToolFormat.PCL, spec.resolveOutput(Map.of("outputFormat", "pcl")).format()); + assertEquals(ToolFormat.XPS, spec.resolveOutput(Map.of("outputFormat", "xps")).format()); + // The request pattern accepts any casing, so the declaration must too. + assertEquals( + ToolFormat.POSTSCRIPT, spec.resolveOutput(Map.of("outputFormat", "PS")).format()); + } + + @Test + void everyVectorOutputFormatHasACase() { + // Guards the list above against a new allowableValue being added without a declaration. + Set declared = + spec("/api/v1/convert/pdf/vector").cases().stream() + .flatMap(rule -> rule.when().stream()) + .flatMap(when -> when.matches().stream()) + .collect(java.util.stream.Collectors.toSet()); + assertEquals(Set.of("ps", "pcl", "xps"), declared); + } + + @Test + void onlyRemovePasswordAcceptsAnEncryptedDocument() { + assertTrue( + spec("/api/v1/security/remove-password").acceptsFormat(ToolFormat.PDF_ENCRYPTED)); + List accepting = + DECLARED.entrySet().stream() + .filter(e -> e.getValue().acceptsFormat(ToolFormat.PDF_ENCRYPTED)) + .map(Map.Entry::getKey) + .filter(path -> !path.equals("/api/v1/security/remove-password")) + .filter(path -> !spec(path).accepts().contains(ToolFormat.ANY)) + .sorted() + .toList(); + assertTrue( + accepting.isEmpty(), + () -> + "Only remove-password should accept an encrypted PDF. If one of these" + + " genuinely handles encryption, say so in its @ToolIO:\n " + + String.join("\n ", accepting)); + } + + private static ToolIOSpec spec(String path) { + ToolIOSpec spec = DECLARED.get(path); + if (spec == null) { + throw new AssertionError("No @ToolIO declared for " + path); + } + return spec; + } + + private static Scan scan() { + Set required = new HashSet<>(); + Map declared = new HashMap<>(); + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(RequestMapping.class, true, true)); + + for (BeanDefinition definition : scanner.findCandidateComponents("stirling.software")) { + Class controller = loadClass(definition.getBeanClassName()); + if (controller == null) { + continue; + } + RequestMapping base = + AnnotatedElementUtils.findMergedAnnotation(controller, RequestMapping.class); + if (base == null || base.value().length == 0) { + continue; + } + for (Method method : controller.getDeclaredMethods()) { + ToolIO declaration = + AnnotatedElementUtils.findMergedAnnotation(method, ToolIO.class); + for (String path : mappedPaths(method)) { + String full = join(base.value()[0], path); + if (!requiresDeclaration(full)) { + continue; + } + required.add(full); + if (declaration != null) { + declared.put(full, ToolIOSpec.from(declaration)); + } + } + } + } + return new Scan(required, declared); + } + + private static boolean requiresDeclaration(String path) { + if (TOOL_NAMESPACES.stream().noneMatch(path::startsWith)) { + return false; + } + // A path variable means it addresses a resource, not a document to transform. + if (path.contains("{")) { + return false; + } + return EXEMPT.stream().noneMatch(e -> path.equals(e) || path.startsWith(e + "/")); + } + + private static List mappedPaths(Method method) { + RequestMapping mapping = + AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class); + if (mapping == null) { + return List.of(); + } + boolean writes = + AnnotatedElementUtils.hasAnnotation(method, PostMapping.class) + || AnnotatedElementUtils.hasAnnotation(method, PutMapping.class) + || Set.of(mapping.method()).stream() + .anyMatch(m -> m.name().equals("POST") || m.name().equals("PUT")); + if (!writes) { + return List.of(); + } + return mapping.value().length == 0 ? List.of("") : Arrays.asList(mapping.value()); + } + + private static String join(String base, String path) { + if (path.isEmpty()) { + return base; + } + return base.endsWith("/") || path.startsWith("/") ? base + path : base + "/" + path; + } + + private static Class loadClass(String name) { + try { + return name == null ? null : Class.forName(name); + } catch (Throwable e) { + // Absent optional dependencies in this flavor; not this test's concern. + return null; + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java index e87d08a863..aad630448f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java @@ -25,6 +25,7 @@ import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.service.ApiDocService; import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.ToolMetadataService; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) @@ -32,6 +33,8 @@ class PipelineProcessorTest { @Mock ApiDocService apiDocService; + @Mock ToolMetadataService toolMetadataService; + @Mock InternalApiClient internalApiClient; @Mock TempFileManager tempFileManager; @@ -41,7 +44,8 @@ class PipelineProcessorTest { @BeforeEach void setUp() throws Exception { pipelineProcessor = - new PipelineProcessor(apiDocService, internalApiClient, tempFileManager); + new PipelineProcessor( + apiDocService, toolMetadataService, internalApiClient, tempFileManager); } @Test @@ -55,8 +59,9 @@ class PipelineProcessorTest { Resource file = new MyFileByteArrayResource(); List files = List.of(file); - when(apiDocService.isMultiInput("/api/v1/filter/filter-page-count")).thenReturn(false); - when(apiDocService.getExtensionTypes(false, "/api/v1/filter/filter-page-count")) + when(toolMetadataService.isMultiInput("/api/v1/filter/filter-page-count")) + .thenReturn(false); + when(toolMetadataService.getExtensionTypes(false, "/api/v1/filter/filter-page-count")) .thenReturn(List.of("pdf")); when(apiDocService.isValidOperation(eq("/api/v1/filter/filter-page-count"), anyMap())) .thenReturn(true); @@ -99,8 +104,9 @@ class PipelineProcessorTest { } }; - when(apiDocService.isMultiInput(anyString())).thenReturn(false); - when(apiDocService.getExtensionTypes(anyBoolean(), anyString())).thenReturn(List.of("pdf")); + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.getExtensionTypes(anyBoolean(), anyString())) + .thenReturn(List.of("pdf")); when(apiDocService.isValidOperation(anyString(), anyMap())).thenReturn(true); when(internalApiClient.post(anyString(), any())) diff --git a/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java index 42b56a9cfa..1fce6a3836 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java @@ -3,14 +3,11 @@ package stirling.software.SPDF.service; import static org.junit.jupiter.api.Assertions.*; import java.lang.reflect.Field; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -23,6 +20,11 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; +/** + * Input/output type behaviour used to live here, parsed out of the description prose. It is now + * declared with {@code @ToolIO} and covered by {@code ToolIORegistryTest} and {@code + * ToolIODeclarationCoverageTest}. + */ @ExtendWith(MockitoExtension.class) class ApiDocServiceTest { @@ -52,199 +54,6 @@ class ApiDocServiceTest { field.set(apiDocService, mapper.createObjectNode()); } - @Test - void getExtensionTypesReturnsExpectedList() throws Exception { - String json = "{\"description\": \"Output:PDF\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/test", postNode); - - setApiDocumentation(Map.of("/test", endpoint)); - setApiDocsJsonRootNode(); - - List extensions = apiDocService.getExtensionTypes(true, "/test"); - assertEquals(List.of("pdf"), extensions); - } - - @Test - void getExtensionTypesHandlesUnknownOperation() throws Exception { - setApiDocumentation(Map.of()); - - List extensions = apiDocService.getExtensionTypes(true, "/unknown"); - assertNull(extensions); - } - - @Test - void getExtensionTypesReturnsImageTypes() throws Exception { - String json = "{\"description\": \"Output:IMAGE\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/img", postNode); - setApiDocumentation(Map.of("/img", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/img"); - assertNotNull(extensions); - assertTrue(extensions.contains("png")); - assertTrue(extensions.contains("jpg")); - assertTrue(extensions.contains("gif")); - } - - @Test - void getExtensionTypesReturnsZipTypes() throws Exception { - String json = "{\"description\": \"Output:ZIP\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/zip", postNode); - setApiDocumentation(Map.of("/zip", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/zip"); - assertNotNull(extensions); - assertTrue(extensions.contains("zip")); - assertTrue(extensions.contains("rar")); - } - - @Test - void getExtensionTypesReturnsWordTypes() throws Exception { - String json = "{\"description\": \"Output:WORD\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/word", postNode); - setApiDocumentation(Map.of("/word", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/word"); - assertNotNull(extensions); - assertTrue(extensions.contains("doc")); - assertTrue(extensions.contains("docx")); - } - - @Test - void getExtensionTypesReturnsCsvTypes() throws Exception { - String json = "{\"description\": \"Output:CSV\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/csv", postNode); - setApiDocumentation(Map.of("/csv", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/csv"); - assertEquals(List.of("csv"), extensions); - } - - @Test - void getExtensionTypesReturnsHtmlTypes() throws Exception { - String json = "{\"description\": \"Output:HTML\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/html", postNode); - setApiDocumentation(Map.of("/html", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/html"); - assertNotNull(extensions); - assertTrue(extensions.contains("html")); - assertTrue(extensions.contains("htm")); - } - - @Test - void getExtensionTypesReturnsBookTypes() throws Exception { - String json = "{\"description\": \"Output:BOOK\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/book", postNode); - setApiDocumentation(Map.of("/book", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/book"); - assertNotNull(extensions); - assertTrue(extensions.contains("epub")); - assertTrue(extensions.contains("mobi")); - } - - @Test - void getExtensionTypesReturnsJsonTypes() throws Exception { - String json = "{\"description\": \"Output:JSON\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/json", postNode); - setApiDocumentation(Map.of("/json", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/json"); - assertEquals(List.of("json"), extensions); - } - - @Test - void getExtensionTypesReturnsTxtTypes() throws Exception { - String json = "{\"description\": \"Output:TXT\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/txt", postNode); - setApiDocumentation(Map.of("/txt", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/txt"); - assertNotNull(extensions); - assertTrue(extensions.contains("txt")); - assertTrue(extensions.contains("md")); - } - - @Test - void getExtensionTypesReturnsPptTypes() throws Exception { - String json = "{\"description\": \"Output:PPT\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/ppt", postNode); - setApiDocumentation(Map.of("/ppt", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/ppt"); - assertNotNull(extensions); - assertTrue(extensions.contains("ppt")); - assertTrue(extensions.contains("pptx")); - } - - @Test - void getExtensionTypesReturnsXmlTypes() throws Exception { - String json = "{\"description\": \"Output:XML\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/xml", postNode); - setApiDocumentation(Map.of("/xml", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/xml"); - assertNotNull(extensions); - assertTrue(extensions.contains("xml")); - } - - @Test - void getExtensionTypesReturnsJsTypes() throws Exception { - String json = "{\"description\": \"Output:JS\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/js", postNode); - setApiDocumentation(Map.of("/js", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/js"); - assertNotNull(extensions); - assertTrue(extensions.contains("js")); - assertTrue(extensions.contains("jsx")); - } - - @Test - void getExtensionTypesWithInputMode() throws Exception { - String json = "{\"description\": \"Input:PDF\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/test-input", postNode); - setApiDocumentation(Map.of("/test-input", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(false, "/test-input"); - assertEquals(List.of("pdf"), extensions); - } - - @Test - void getExtensionTypesReturnsNullWhenNoTypeMatch() throws Exception { - String json = "{\"description\": \"No type here\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/notype", postNode); - setApiDocumentation(Map.of("/notype", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/notype"); - assertNull(extensions); - } - - @Test - void getExtensionTypesReturnsNullForUnknownOutputType() throws Exception { - String json = "{\"description\": \"Output:UNKNOWNTYPE\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/unk", postNode); - setApiDocumentation(Map.of("/unk", endpoint)); - setApiDocsJsonRootNode(); - List extensions = apiDocService.getExtensionTypes(true, "/unk"); - assertNull(extensions); - } - @Test void isValidOperationChecksRequiredParameters() throws Exception { String json = @@ -304,184 +113,9 @@ class ApiDocServiceTest { assertFalse(apiDocService.isValidOperation("/mixed", Map.of())); } - @Test - void isMultiInputDetectsTypeMI() throws Exception { - String json = "{\"description\": \"Type:MI\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/multi", postNode); - setApiDocumentation(Map.of("/multi", endpoint)); - setApiDocsJsonRootNode(); - assertTrue(apiDocService.isMultiInput("/multi")); - } - - @Test - void isMultiInputDetectsUnknownOperation() throws Exception { - setApiDocumentation(Map.of()); - assertFalse(apiDocService.isMultiInput("/unknown")); - } - - @Test - void isMultiInputHandlesNoDescription() throws Exception { - String json = "{\"parameters\": [{\"name\":\"param1\"}, {\"name\":\"param2\"}]}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/multi", postNode); - setApiDocumentation(Map.of("/multi", endpoint)); - setApiDocsJsonRootNode(); - assertFalse(apiDocService.isMultiInput("/multi")); - } - - @Test - void isMultiInputReturnsFalseForNonMIType() throws Exception { - String json = "{\"description\": \"Type:SI\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/single", postNode); - setApiDocumentation(Map.of("/single", endpoint)); - setApiDocsJsonRootNode(); - assertFalse(apiDocService.isMultiInput("/single")); - } - - @Test - void isMultiInputReturnsTrueForMISO() throws Exception { - String json = "{\"description\": \"Type:MISO\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/miso", postNode); - setApiDocumentation(Map.of("/miso", endpoint)); - setApiDocsJsonRootNode(); - assertTrue(apiDocService.isMultiInput("/miso")); - } - - @Test - void shouldUnpackZipResponseDetectsMultiOutputType() throws Exception { - String json = "{\"description\": \"Output:PDF Type:SIMO\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/split", postNode); - setApiDocumentation(Map.of("/split", endpoint)); - setApiDocsJsonRootNode(); - assertTrue(apiDocService.shouldUnpackZipResponse("/split")); - } - - @Test - void shouldUnpackZipResponseDetectsMimoType() throws Exception { - String json = "{\"description\": \"Output:PDF Type:MIMO\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/overlay", postNode); - setApiDocumentation(Map.of("/overlay", endpoint)); - setApiDocsJsonRootNode(); - assertTrue(apiDocService.shouldUnpackZipResponse("/overlay")); - } - - @Test - void shouldUnpackZipResponseDetectsZipOutputDeclaration() throws Exception { - String json = "{\"description\": \"Output:ZIP-PDF Type:SISO\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/split-by-sections", postNode); - setApiDocumentation(Map.of("/split-by-sections", endpoint)); - setApiDocsJsonRootNode(); - assertTrue(apiDocService.shouldUnpackZipResponse("/split-by-sections")); - } - - @Test - void shouldUnpackZipResponseReturnsFalseForSisoPdf() throws Exception { - String json = "{\"description\": \"Input:PDF Output:PDF Type:SISO\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/rotate", postNode); - setApiDocumentation(Map.of("/rotate", endpoint)); - setApiDocsJsonRootNode(); - assertFalse(apiDocService.shouldUnpackZipResponse("/rotate")); - } - - @Test - void shouldUnpackZipResponseReturnsFalseForUnknownOperation() throws Exception { - setApiDocumentation(Map.of()); - assertFalse(apiDocService.shouldUnpackZipResponse("/unknown")); - } - - /** - * Coverage test: every Stirling endpoint whose ZIP response is a transport for multiple typed - * results (SIMO/MIMO or Output:ZIP-PDF / Output:IMAGE/ZIP etc.) must be classified as {@code - * shouldUnpackZipResponse = true}. Descriptions below are the real - * {@code @Operation(description=...)} strings from each controller, so if a controller is - * renamed, tweaked or introduced without a {@code Type:} / {@code Output:ZIP-*} tag, this test - * breaks, surfacing the bug before {@code AiWorkflowService} silently registers a multi-result - * ZIP as a single file. - * - *

Add a new row here whenever a new unpack-eligible endpoint is introduced. Descriptions can - * be trimmed to the part containing the relevant tags. - */ - @ParameterizedTest(name = "{0} → shouldUnpackZipResponse") - @CsvSource( - textBlock = - """ - /api/v1/general/split-pages, 'Split pages. Input:PDF Output:PDF Type:SIMO' - /api/v1/general/split-pdf-by-sections, 'Split. Input:PDF Output:ZIP-PDF Type:SISO' - /api/v1/general/split-by-size-or-count, 'Split by size. Input:PDF Output:ZIP-PDF Type:SISO' - /api/v1/general/split-pdf-by-chapters, 'Split by chapters. Input:PDF Output:ZIP-PDF Type:SISO' - /api/v1/general/split-for-poster-print, 'Poster split. Input: PDF Output: ZIP-PDF Type: SISO' - /api/v1/general/overlay-pdfs, 'Overlay PDFs. Input:PDF Output:PDF Type:MIMO' - /api/v1/misc/auto-split-pdf, 'Auto split. Input:PDF Output:ZIP-PDF Type:SISO' - /api/v1/misc/extract-images, 'Extract images. Output:IMAGE/ZIP Type:SIMO' - /api/v1/misc/extract-image-scans, 'Extract image scans. Input:PDF Output:IMAGE/ZIP Type:SIMO' - """) - void shouldUnpackZipResponseClassifiesKnownUnpackableEndpoints( - String endpoint, String description) throws Exception { - String json = mapper.writeValueAsString(Map.of("description", description)); - JsonNode postNode = mapper.readTree(json); - setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode))); - setApiDocsJsonRootNode(); - assertTrue( - apiDocService.shouldUnpackZipResponse(endpoint), - () -> - "Expected shouldUnpackZipResponse=true for " - + endpoint - + " with description: " - + description); - } - - /** - * Inverse coverage: endpoints whose ZIP response is the deliverable itself (or that return - * single non-ZIP files) must not be flagged for unpacking. Catches regressions where a change - * to the classifier accidentally widens the positive match. - */ - @ParameterizedTest(name = "{0} → !shouldUnpackZipResponse") - @CsvSource( - textBlock = - """ - /api/v1/general/rotate-pdf, 'Rotate. Input:PDF Output:PDF Type:SISO' - /api/v1/general/merge-pdfs, 'Merge. Input:PDF Output:PDF Type:MISO' - /api/v1/misc/compress-pdf, 'Compress. Input:PDF Output:PDF Type:SISO' - /api/v1/misc/flatten, 'Flatten forms. Input:PDF Output:PDF Type:SISO' - /api/v1/security/get-attachments, 'Extract attachments. Input:PDF Output:ZIP Type:SISO' - """) - void shouldUnpackZipResponseRejectsNonUnpackableEndpoints(String endpoint, String description) - throws Exception { - String json = mapper.writeValueAsString(Map.of("description", description)); - JsonNode postNode = mapper.readTree(json); - setApiDocumentation(Map.of(endpoint, new ApiEndpoint(endpoint, postNode))); - setApiDocsJsonRootNode(); - assertFalse( - apiDocService.shouldUnpackZipResponse(endpoint), - () -> - "Expected shouldUnpackZipResponse=false for " - + endpoint - + " with description: " - + description); - } - @Test void constructorAcceptsNullUserService() { ApiDocService service = new ApiDocService(mapper, servletContext, null); assertNotNull(service); } - - @Test - void getExtensionTypesInitializesMapOnFirstCall() throws Exception { - String json = "{\"description\": \"Output:PDF\"}"; - JsonNode postNode = mapper.readTree(json); - ApiEndpoint endpoint = new ApiEndpoint("/test", postNode); - setApiDocumentation(Map.of("/test", endpoint)); - setApiDocsJsonRootNode(); - List first = apiDocService.getExtensionTypes(true, "/test"); - List second = apiDocService.getExtensionTypes(true, "/test"); - assertEquals(first, second); - } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java index 8beddd5e94..d03c176c23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java @@ -68,8 +68,6 @@ public class MathAuditorAgentController { Returns a JSON Verdict describing every discrepancy found. How the Verdict is presented to the end user (chat answer, PDF annotations, etc.) is up to the caller. - - Input: PDF Output: JSON Type: SISO """) public ResponseEntity mathAuditorAgent( @Parameter(description = "The PDF document to audit", required = true) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java index fadfacf03a..db9a89a867 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java @@ -67,8 +67,6 @@ public class PdfCommentAgentController { The annotated PDF is streamed back in the response body with Content-Type: application/pdf. - - Input: PDF + prompt Output: PDF Type: SISO """) public ResponseEntity pdfCommentAgent( @Parameter(description = "The PDF document to annotate", required = true) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java index 499ab90fdb..bb32d85092 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java @@ -27,6 +27,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.InternalApiClient; import stirling.software.common.util.TempFileManager; @@ -89,7 +91,10 @@ public class ExternalApiCallController { private final TempFileManager tempFileManager; private final ApplicationProperties applicationProperties; + // The document is forwarded as bytes and never parsed, and in 'replace' mode the response + // becomes the document, so neither side can be pinned to a format. @PostMapping(value = "/external-api-call", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ToolIO(accepts = ToolFormat.ANY, produces = ToolFormat.ANY) @Operation( summary = "Send the document to an external API", description = @@ -97,7 +102,7 @@ public class ExternalApiCallController { + " either records the response as a step report or replaces the" + " document with it. Fields, path and headers may reference" + " {{document.*}}, {{classification.*}}, {{sensitivityLabel.*}} and" - + " {{run.*}}. Type:SISO") + + " {{run.*}}.") public ResponseEntity call( @RequestParam("fileInput") MultipartFile fileInput, @RequestParam("connectionId") String connectionId, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java index 65555077c3..666e7e5620 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java @@ -24,6 +24,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @@ -59,13 +61,13 @@ public class PurviewLabelController { private final ObjectMapper objectMapper; @PostMapping(value = "/purview-apply-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Apply a Microsoft Purview sensitivity label", description = "Writes the Purview label metadata (MSIP_Label__*) onto the PDF, so" + " Purview-aware tools recognise the label. Applies the label only;" - + " it cannot encrypt, which requires the Microsoft client." - + " Input:PDF Output:PDF Type:SISO") + + " it cannot encrypt, which requires the Microsoft client.") public ResponseEntity applyLabel( @RequestParam("fileInput") MultipartFile fileInput, @RequestParam("connectionId") String connectionId, @@ -95,12 +97,12 @@ public class PurviewLabelController { } @PostMapping(value = "/purview-read-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ToolIO(produces = ToolFormat.PDF) @Operation( summary = "Read the Microsoft Purview sensitivity label on a PDF", description = "Reports the Purview labels a PDF already carries so a policy can act on" - + " them. The document passes through unchanged." - + " Input:PDF Output:PDF Type:SISO") + + " them. The document passes through unchanged.") public ResponseEntity readLabel( @RequestParam("fileInput") MultipartFile fileInput, @RequestParam("connectionId") String connectionId) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index b6156bc2b8..a382f96bbc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -44,7 +44,9 @@ import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; +import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.ToolChainValidator; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.audit.AuditContext; @@ -59,6 +61,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.PipelineValidation; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; @@ -239,6 +242,23 @@ public class PolicyController { // --- Policy management --- + @PostMapping(value = "/validate", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Check whether a chain of steps can run", + description = + "Reports which steps cannot accept what the step before them produces, without" + + " running anything or storing anything. Saving already rejects a" + + " chain whose steps cannot run; this answers the same question up" + + " front, and also returns the warnings and fan-out notes that saving" + + " does not.") + public PipelineValidation.Response validateChain( + @RequestBody PipelineValidation.Request request) { + List diagnostics = + policyValidator.diagnoseChain(request.steps(), request.sourceFormat()); + return new PipelineValidation.Response( + !ToolChainValidator.hasErrors(diagnostics), diagnostics); + } + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @Operation( summary = "Create or update a policy", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index b30a760fe3..7d9a8cf88b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -6,6 +6,9 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.tool.ToolDiagnostic; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.service.ToolChainValidator; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -34,6 +37,7 @@ public class PolicyValidator { private final List outputSinks; private final List stepValidators; private final SourceStore sourceStore; + private final ToolChainValidator toolChainValidator; /** * @throws IllegalArgumentException if the policy has more than one input or output, any facet's @@ -65,9 +69,49 @@ public class PolicyValidator { inputSourceFor(spec).validate(spec); } validateSteps(policy.steps()); + validateChain(policy.steps()); validateOutput(policy.output()); } + /** + * Reject a chain whose steps cannot run on each other. Such a policy saves fine today and only + * fails part-way through its first run, which for a scheduled one may be much later. + * + *

Only errors block; warnings depend on configuration or file content. + * + * @throws IllegalArgumentException if any step cannot accept what the one before it produces + */ + public void validateChain(List steps) { + List diagnostics = diagnoseChain(steps, null); + if (!ToolChainValidator.hasErrors(diagnostics)) { + return; + } + String reasons = + diagnostics.stream() + .filter(d -> d.severity() == ToolDiagnostic.Severity.ERROR) + .map(d -> "step " + (d.stepIndex() + 1) + ": " + d.message()) + .reduce((a, b) -> a + "; " + b) + .orElse(""); + throw new IllegalArgumentException("pipeline steps cannot run in this order - " + reasons); + } + + /** + * Everything wrong with a chain, without rejecting it, so a caller can show warnings and + * fan-out notes too. + * + * @param sourceFormat the format entering the first step, or null when unknown + */ + public List diagnoseChain(List steps, ToolFormat sourceFormat) { + List chain = + steps.stream() + .map( + step -> + new ToolChainValidator.Step( + step.operation(), step.parameters())) + .toList(); + return toolChainValidator.validate(chain, sourceFormat); + } + /** * Validate each step against every registered {@link PipelineStepValidator}. Must be called on * a request thread (caller's principal present) for the same reason as {@link diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineValidation.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineValidation.java new file mode 100644 index 0000000000..53eba97aa6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineValidation.java @@ -0,0 +1,39 @@ +package stirling.software.proprietary.policy.model; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import stirling.software.common.model.tool.ToolDiagnostic; +import stirling.software.common.model.tool.ToolFormat; + +/** + * Checking whether a policy's steps can run on each other, without the files they would run on. + * + *

The same check runs at save time in {@code PolicyValidator}. This exposes it on its own so a + * chain can be checked before it is saved, and so callers that are not the editor can ask. + */ +public final class PipelineValidation { + + private PipelineValidation() {} + + /** + * @param steps the steps to check, in the order they would run + * @param sourceFormat the format of the files entering the first step, or null to check only + * that the steps fit together + */ + @Schema(description = "A chain of steps to check") + public record Request(List steps, ToolFormat sourceFormat) { + + public List steps() { + return steps == null ? List.of() : steps; + } + } + + /** + * @param valid false when at least one diagnostic is an error, meaning the chain cannot run + * @param diagnostics every problem found, each against the step that cannot run + */ + @Schema(description = "Whether a chain of steps can run, and what is wrong with it if not") + public record Response(boolean valid, List diagnostics) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java index 213e08ea0c..8145531dc0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java @@ -46,9 +46,7 @@ public class EmailController { resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "Send an email with an attachment", - description = - "This endpoint sends an email with an attachment. Input:PDF" - + " Output:Success/Failure Type:MISO") + description = "This endpoint sends an email with an attachment.") public ResponseEntity sendEmailWithAttachment(@Valid @ModelAttribute Email email) { log.info("Sending email to: {}", email.toString()); try { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 2464b6ed62..4e95707217 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -93,7 +93,7 @@ public class SigningSessionController { summary = "Create a shared signing session", description = "Starts a collaboration session, distributes share links, and optionally notifies" - + " participants. Input:PDF Output:JSON Type:SISO") + + " participants.") public ResponseEntity createSession( @org.springframework.web.bind.annotation.RequestParam("file") org.springframework.web.multipart.MultipartFile file, diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index c29c96a8ed..56e5b2d9cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -2,7 +2,11 @@ package stirling.software.proprietary.policy.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @@ -31,6 +35,7 @@ import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.inprocess.InProcessJobStore; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; +import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.policy.config.PolicyAccessGuard; @@ -44,6 +49,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.PipelineValidation; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunView; @@ -113,6 +119,59 @@ class PolicyControllerTest { jobStore); } + @Test + void validateReportsAChainThatCannotRun() { + // Delegates to PolicyValidator so the endpoint and the save-time gate cannot disagree. + ToolDiagnostic mismatch = + ToolDiagnostic.error( + 1, ToolDiagnostic.FORMAT_MISMATCH, "rotate cannot take an image"); + when(policyValidator.diagnoseChain(anyList(), any())).thenReturn(List.of(mismatch)); + + PipelineValidation.Response response = + controller.validateChain( + new PipelineValidation.Request( + List.of( + new PipelineStep( + "/api/v1/misc/extract-images", Map.of(), Map.of()), + new PipelineStep( + "/api/v1/general/rotate-pdf", Map.of(), Map.of())), + null)); + + assertFalse(response.valid()); + assertEquals(List.of(mismatch), response.diagnostics()); + } + + @Test + void validateReportsAWorkableChainAsValid() { + // Warnings and fan-out notes come back without making the chain invalid. + ToolDiagnostic fanOut = + ToolDiagnostic.info(1, ToolDiagnostic.FAN_OUT, "runs once per file"); + when(policyValidator.diagnoseChain(anyList(), any())).thenReturn(List.of(fanOut)); + + PipelineValidation.Response response = + controller.validateChain( + new PipelineValidation.Request( + List.of( + new PipelineStep( + "/api/v1/general/split-pages", Map.of(), Map.of()), + new PipelineStep( + "/api/v1/general/rotate-pdf", Map.of(), Map.of())), + null)); + + assertTrue(response.valid()); + assertEquals(List.of(fanOut), response.diagnostics()); + } + + @Test + void validateToleratesNoSteps() { + when(policyValidator.diagnoseChain(anyList(), any())).thenReturn(List.of()); + + PipelineValidation.Response response = + controller.validateChain(new PipelineValidation.Request(null, null)); + + assertTrue(response.valid()); + } + private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger( String type, boolean requiresSource, java.util.Set sourceTypes) { return new stirling.software.proprietary.policy.trigger.PolicyTrigger() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java index f1be12ae1d..e7081ec8ca 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java @@ -72,7 +72,9 @@ class PolicyRunRoutesTest { private static final Map EXPECTED_ID_EXECUTES = Map.of( "/api/v1/policies/{policyId}/run", true, - "/api/v1/policies/{policyId}/trigger", true); + "/api/v1/policies/{policyId}/trigger", true, + // Checks whether a chain could run; runs and stores nothing, so not billable. + "/api/v1/policies/validate", false); /** * Fail-safe: this matcher is the sole billing gate, so an unmatched execute route would run diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 6440cefa4a..074adc2b36 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,10 +18,16 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.common.model.tool.ToolArity; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIOSource; +import stirling.software.common.model.tool.ToolIOSpec; +import stirling.software.common.service.ToolChainValidator; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; +import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; @@ -49,7 +56,8 @@ class PolicyValidatorTest { List.of(inputSource), List.of(outputSink), List.of(stepValidator), - sourceStore); + sourceStore, + new ToolChainValidator(path -> java.util.Optional.empty())); } @Test @@ -123,6 +131,75 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("unknown trigger type")); } + @Test + void rejectsAChainWhoseStepsCannotRunOnEachOther() { + // Extracting images then rotating them saves fine today and fails part-way through the + // first run, which for a scheduled policy can be long after the mistake was made. + // The chain is checked before the output, so the sink is never reached here. + when(inputSource.supports(any())).thenReturn(true); + PolicyValidator strict = validatorWith(IMAGE_THEN_PDF); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> strict.validate(withSteps(EXTRACT_IMAGES, ROTATE))); + + assertTrue(ex.getMessage().contains("cannot run in this order"), ex.getMessage()); + } + + @Test + void acceptsAChainWhoseStepsLineUp() { + when(inputSource.supports(any())).thenReturn(true); + when(outputSink.supports(any())).thenReturn(true); + PolicyValidator strict = validatorWith(IMAGE_THEN_PDF); + + strict.validate(withSteps(ROTATE, ROTATE)); + + verify(outputSink).validate(any()); + } + + private static final String EXTRACT_IMAGES = "/api/v1/misc/extract-images"; + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + + private static final ToolIOSource IMAGE_THEN_PDF = + ToolIOSource.of( + Map.of( + EXTRACT_IMAGES, + new ToolIOSpec( + Set.of(ToolFormat.PDF), + ToolFormat.IMAGE, + ToolArity.SIMO, + List.of()), + ROTATE, + new ToolIOSpec( + Set.of(ToolFormat.PDF), + ToolFormat.PDF, + ToolArity.SISO, + List.of()))); + + private PolicyValidator validatorWith(ToolIOSource toolIO) { + return new PolicyValidator( + List.of(trigger), + List.of(inputSource), + List.of(outputSink), + List.of(stepValidator), + sourceStore, + new ToolChainValidator(toolIO)); + } + + private Policy withSteps(String... operations) { + return new Policy( + "p1", + "p", + "owner", + true, + List.of(PipelineInput.manual(folderSourceId())), + java.util.Arrays.stream(operations) + .map(op -> new PipelineStep(op, Map.of(), Map.of())) + .toList(), + OutputSpec.inline()); + } + // The one-input/one-output caps are a product decision, not a model limit: the lists stay so // multiple can be supported later, but saving more than one of either is rejected today. diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py index dfe461fcca..2a5139a263 100644 --- a/engine/scripts/generate_tool_models.py +++ b/engine/scripts/generate_tool_models.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Generate Python tool models from the Java backend's OpenAPI spec (SwaggerDoc.json). +"""Generate the Python files derived from the Java OpenAPI spec (SwaggerDoc.json). -Uses datamodel-code-generator to convert OpenAPI request schemas to Pydantic models. -Run via: +tool_models.py holds each tool's request model; tool_io.py holds what it accepts and produces, +from ``@ToolIO`` via the ``x-stirling-io`` extension. One pass over one spec, so the two cannot +drift apart. Run via: task engine:tool-models """ @@ -10,6 +11,7 @@ from __future__ import annotations import argparse import json +import subprocess from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path @@ -24,8 +26,69 @@ from referencing.jsonschema import DRAFT202012 # Fields inherited from PDFFile base class - not tool parameters. BASE_CLASS_FIELDS = frozenset({"fileInput", "fileId"}) +IO_EXTENSION = "x-stirling-io" +IO_VOCABULARY_EXTENSION = "x-stirling-io-vocabulary" + _ENGINE_ROOT = Path(__file__).resolve().parents[1] +_IO_TEMPLATE = '''# AUTO-GENERATED FILE. DO NOT EDIT. +# Generated by scripts/generate_tool_models.py from the Java OpenAPI spec (SwaggerDoc.json). +# Regenerate with: task engine:tool-models +"""What each tool endpoint accepts and produces, so a planned chain can be checked before it +is run. Declared in Java with ``@ToolIO``; see ``stirling.services.tool_io_compat`` for the +compatibility rules that read this table.""" + +from enum import StrEnum + +from pydantic import Field + +from stirling.models.base import ApiModel +from stirling.models.tool_models import ToolEndpoint + + +class ToolFormat(StrEnum): + """The kind of file a tool consumes or produces. ``ANY`` accepts or produces anything; + ``NONE`` means no file at all, such as a report or a status.""" + +{formats} + + +class ToolArity(StrEnum): + """How many files go in and come out (Single/Multiple In, Single/Multiple Out). A + multi-output tool returns its results zipped, and the caller unpacks them.""" + +{arities} + + +class ToolIOWhen(ApiModel): + """One condition on a request parameter, guarding a :class:`ToolIOCase`.""" + + param: str + matches: list[str] + + +class ToolIOCase(ApiModel): + """An output that applies when every condition in ``when`` holds.""" + + when: list[ToolIOWhen] + produces: ToolFormat + arity: ToolArity + + +class ToolIOSpec(ApiModel): + """What one endpoint accepts and produces.""" + + accepts: list[ToolFormat] + produces: ToolFormat + arity: ToolArity + cases: list[ToolIOCase] = Field(default_factory=list) + + +TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{ +{declarations} +}} +''' + _FILE_HEADER = ( "# AUTO-GENERATED FILE. DO NOT EDIT.\n" "# Generated by scripts/generate_tool_models.py from Java OpenAPI spec (SwaggerDoc.json).\n" @@ -281,7 +344,7 @@ def generate_models_code(combined_schema: dict[str, Any]) -> str: return str(code or "") -def write_output(out_path: Path, tools: list[ToolSpec], models_code: str) -> None: +def render_models(tools: list[ToolSpec], models_code: str) -> str: union_lines = ["type ParamToolModel = ("] for i, tool in enumerate(tools): prefix = " | " if i > 0 else " " @@ -301,30 +364,119 @@ def write_output(out_path: Path, tools: list[ToolSpec], models_code: str) -> Non ] parts = [models_code, "\n", *union_lines, "\n", *enum_lines, "\n", *ops_lines, ""] - out_path.write_text("\n".join(parts), encoding="utf-8") + return "\n".join(parts) + + +def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: + """The ``x-stirling-io`` declaration for every endpoint that carries one.""" + table: dict[str, dict[str, Any]] = {} + for path, path_item in sorted(spec.get("paths", {}).items()): + for operation in path_item.values(): + if isinstance(operation, dict) and operation.get(IO_EXTENSION): + table[path] = operation[IO_EXTENSION] + return table + + +def _render_when(condition: dict[str, Any]) -> str: + return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})" + + +def _render_case(case: dict[str, Any]) -> str: + when = ", ".join(_render_when(condition) for condition in case["when"]) + return f"ToolIOCase(when=[{when}], produces=ToolFormat.{case['produces']}, arity=ToolArity.{case['arity']})" + + +def _render_spec(declaration: dict[str, Any]) -> str: + """One declaration as a constructor call, so a bad spec is a type error at import rather + than a validation failure at first use.""" + accepts = ", ".join(f"ToolFormat.{f}" for f in declaration["accepts"]) + parts = [ + f"accepts=[{accepts}]", + f"produces=ToolFormat.{declaration['produces']}", + f"arity=ToolArity.{declaration['arity']}", + ] + cases = declaration.get("cases") + if cases: + parts.append("cases=[" + ", ".join(_render_case(case) for case in cases) + "]") + return f"ToolIOSpec({', '.join(parts)})" + + +def _members(values: list[str]) -> str: + return "\n".join(f' {value} = "{value}"' for value in values) + + +def render_tool_io(spec: dict[str, Any], tools: list[ToolSpec]) -> str: + """Keyed by ``ToolEndpoint`` so the endpoint strings live in one place and lookups are checked. + + Declarations outside the enum - the filters, and the introspection endpoints the agent never + plans - are dropped. + """ + by_path = {tool.path: tool.enum_name for tool in tools} + table = {path: d for path, d in collect_tool_io(spec).items() if path in by_path} + if not table: + raise SystemExit( + f"No {IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; " + "regenerate the spec with 'task backend:swagger'." + ) + # Published separately: deriving the enums from the declarations present would shrink them + # whenever an endpoint is disabled in a build. + vocabulary = spec.get(IO_VOCABULARY_EXTENSION) + if not vocabulary: + raise SystemExit(f"No {IO_VOCABULARY_EXTENSION} in the spec; regenerate it from a current backend.") + + rendered = _IO_TEMPLATE.format( + formats=_members(vocabulary["formats"]), + arities=_members(vocabulary["arities"]), + declarations="\n".join( + f" ToolEndpoint.{by_path[path]}: {_render_spec(declaration)}," + for path, declaration in sorted(table.items()) + ), + ) + # Formatted before writing so --check compares like for like. + return subprocess.run( + ["ruff", "format", "--stdin-filename", "tool_io.py", "-"], + input=rendered, + capture_output=True, + text=True, + check=True, + cwd=_ENGINE_ROOT, + ).stdout + + +def write_or_check(out_path: Path, rendered: str, check: bool) -> None: + """In check mode, fail when the committed file is out of date.""" + if check: + current = out_path.read_text(encoding="utf-8") if out_path.exists() else "" + if current != rendered: + raise SystemExit(f"{out_path} is out of date. Run 'task engine:tool-models' and commit the result.") + return + out_path.write_text(rendered, encoding="utf-8") def main() -> None: - parser = argparse.ArgumentParser(description="Generate Python tool models from Java OpenAPI spec") + parser = argparse.ArgumentParser(description="Generate the Python files derived from the Java OpenAPI spec") parser.add_argument("--spec", required=True, help="Path to SwaggerDoc.json") parser.add_argument("--output", required=True, help="Path to output tool_models.py") + parser.add_argument("--io-output", required=True, help="Path to output tool_io.py") + parser.add_argument("--check", action="store_true", help="Fail if a committed file is out of date") args = parser.parse_args() spec_path = Path(args.spec) if not spec_path.exists(): - raise SystemExit(f"OpenAPI spec not found at {spec_path}\nRun 'task engine:tool-models' to generate it.") - output_path = Path(args.output) + raise SystemExit(f"OpenAPI spec not found at {spec_path}\nRun 'task backend:swagger' to generate it.") with open(spec_path, encoding="utf-8") as f: spec = json.load(f) result = ToolDiscovery(spec).discover() - models_code = generate_models_code(result.combined_schema) - write_output(output_path, result.tools, models_code) - print(f"Generated {len(result.tools)} tool models from {spec_path.name}") - for tool in result.tools: - print(f" {tool.enum_name}: {tool.path} -> {tool.class_name}") + io_table = render_tool_io(spec, result.tools) + write_or_check(Path(args.io_output), io_table, args.check) + print(f"{'Up to date' if args.check else 'Generated'}: {len(result.tools)} tool I/O declarations") + + models_code = generate_models_code(result.combined_schema) + write_or_check(Path(args.output), render_models(result.tools, models_code), args.check) + print(f"{'Up to date' if args.check else 'Generated'}: {len(result.tools)} tool models from {spec_path.name}") if __name__ == "__main__": diff --git a/engine/src/stirling/agents/pdf_edit.py b/engine/src/stirling/agents/pdf_edit.py index cdc4fea4f7..c46fdb9f24 100644 --- a/engine/src/stirling/agents/pdf_edit.py +++ b/engine/src/stirling/agents/pdf_edit.py @@ -27,7 +27,7 @@ from stirling.contracts import ( ) from stirling.logging import Pretty from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint -from stirling.services import AppRuntime +from stirling.services import AppRuntime, ToolChainStep, blocking, validate_tool_chain logger = logging.getLogger(__name__) @@ -209,25 +209,42 @@ class PdfEditAgent: supported_operations, unavailable_operations = self._classify_operations(request) if not supported_operations: return EditCannotDoResponse(reason="No PDF edit operations are available on this server.") - selection = await self._select_plan( - request, supported_operations, unavailable_operations, allow_need_content=allow_need_content - ) - if isinstance(selection, EditClarificationRequest | EditCannotDoResponse): - logger.info("[pdf-edit] selection -> %s: %s", selection.outcome, Pretty(selection)) - return selection - if isinstance(selection, PdfEditNeedContentSelection): - logger.info("[pdf-edit] selection -> need_content: %s", selection.reason) - return self._build_need_content_response(selection, request) - enabled = set(supported_operations) - unsupported = [op for op in selection.operations if op not in enabled] - if unsupported: - logger.warning("[pdf-edit] plan referenced unavailable operations: %s", [op.name for op in unsupported]) - return EditCannotDoResponse( - reason=( - "The following operations are not available on this server " - "(either disabled by the administrator or not installed): " - + ", ".join(op.name for op in unsupported) + # One retry, told exactly which transition fails. Cheaper than carrying the whole + # compatibility matrix in the prompt. + repair_note = "" + for attempt in range(2): + selection = await self._select_plan( + request, + supported_operations, + unavailable_operations, + allow_need_content=allow_need_content, + repair_note=repair_note, + ) + if isinstance(selection, EditClarificationRequest | EditCannotDoResponse): + logger.info("[pdf-edit] selection -> %s: %s", selection.outcome, Pretty(selection)) + return selection + if isinstance(selection, PdfEditNeedContentSelection): + logger.info("[pdf-edit] selection -> need_content: %s", selection.reason) + return self._build_need_content_response(selection, request) + enabled = set(supported_operations) + unsupported = [op for op in selection.operations if op not in enabled] + if unsupported: + logger.warning("[pdf-edit] plan referenced unavailable operations: %s", [op.name for op in unsupported]) + return EditCannotDoResponse( + reason=( + "The following operations are not available on this server " + "(either disabled by the administrator or not installed): " + + ", ".join(op.name for op in unsupported) + ) ) + problems = self._chain_problems(selection.operations) + if not problems: + break + logger.warning("[pdf-edit] plan rejected on attempt %d: %s", attempt + 1, problems) + repair_note = problems + else: + return EditCannotDoResponse( + reason=("No workable order of the available operations achieves this: " + repair_note) ) logger.info("[pdf-edit] plan: %s", [op.name for op in selection.operations]) steps: list[ToolOperationStep] = [] @@ -257,6 +274,7 @@ class PdfEditAgent: unavailable_operations: Iterable[ToolEndpoint], *, allow_need_content: bool = True, + repair_note: str = "", ) -> PdfEditPlanOutput: can_request_content = allow_need_content and not has_page_text(request.page_text) agent = self._build_selection_agent( @@ -264,7 +282,9 @@ class PdfEditAgent: unavailable_operations, allow_need_content=can_request_content, ) - return await agent.select(self._build_selection_prompt(request, supported_operations, unavailable_operations)) + return await agent.select( + self._build_selection_prompt(request, supported_operations, unavailable_operations, repair_note) + ) def _build_selection_agent( self, @@ -311,7 +331,18 @@ class PdfEditAgent: request: PdfEditRequest, supported_operations: Iterable[ToolEndpoint], unavailable_operations: Iterable[ToolEndpoint], + repair_note: str = "", ) -> str: + repair_line = ( + f"A previous attempt planned operations in an order that cannot run: {repair_note}\n" + "If running them in a different order still gives the user what they asked for, " + "return that plan. If it would not - reordering changes the result, or no order " + "works - return cannot_do and say plainly which step cannot accept the previous " + "step's output. This is the one case where cannot_do is right even though some " + "order of these operations would run.\n" + if repair_note + else "" + ) unavailable_line = ( "Unavailable operations (exist but not currently usable): " f"{self._get_operations_prompt(unavailable_operations)}\n" @@ -324,6 +355,7 @@ class PdfEditAgent: f"Files: {format_file_names(request.files)}\n" f"Supported operations:\n{self._get_supported_operations_prompt(supported_operations)}\n" f"{unavailable_line}" + f"{repair_line}" f"Extracted page text:\n{format_page_text(request.page_text)}" ) @@ -353,6 +385,20 @@ class PdfEditAgent: unavailable = [op for op in OPERATIONS if op not in enabled_set and op not in self._AGENT_HIDDEN_ENDPOINTS] return supported, unavailable + @staticmethod + def _chain_problems(operations: Iterable[ToolEndpoint]) -> str: + """Why this ordering cannot run, or empty if it can. + + Only errors count: parameters are not chosen yet, so a conditional output merely warns and + acting on that would reject plans that are fine once configured. + """ + diagnostics = validate_tool_chain([ToolChainStep(operation=op) for op in operations]) + errors = blocking(diagnostics) + if not errors: + return "" + steps = list(operations) + return "; ".join(f"step {d.step_index + 1} ({steps[d.step_index].name}) {d.message}" for d in errors) + @staticmethod def _get_operations_prompt(operations: Iterable[ToolEndpoint]) -> str: return ", ".join(f"{op.name} ({op.value})" for op in operations) diff --git a/engine/src/stirling/models/tool_io.py b/engine/src/stirling/models/tool_io.py new file mode 100644 index 0000000000..8c95be59a1 --- /dev/null +++ b/engine/src/stirling/models/tool_io.py @@ -0,0 +1,261 @@ +# AUTO-GENERATED FILE. DO NOT EDIT. +# Generated by scripts/generate_tool_models.py from the Java OpenAPI spec (SwaggerDoc.json). +# Regenerate with: task engine:tool-models +"""What each tool endpoint accepts and produces, so a planned chain can be checked before it +is run. Declared in Java with ``@ToolIO``; see ``stirling.services.tool_io_compat`` for the +compatibility rules that read this table.""" + +from enum import StrEnum + +from pydantic import Field + +from stirling.models.base import ApiModel +from stirling.models.tool_models import ToolEndpoint + + +class ToolFormat(StrEnum): + """The kind of file a tool consumes or produces. ``ANY`` accepts or produces anything; + ``NONE`` means no file at all, such as a report or a status.""" + + PDF = "PDF" + PDF_ENCRYPTED = "PDF_ENCRYPTED" + IMAGE = "IMAGE" + ZIP = "ZIP" + WORD = "WORD" + PPT = "PPT" + EXCEL = "EXCEL" + CSV = "CSV" + HTML = "HTML" + XML = "XML" + JSON = "JSON" + TEXT = "TEXT" + MARKDOWN = "MARKDOWN" + JAVASCRIPT = "JAVASCRIPT" + EBOOK = "EBOOK" + EMAIL = "EMAIL" + POSTSCRIPT = "POSTSCRIPT" + PCL = "PCL" + XPS = "XPS" + VIDEO = "VIDEO" + CBZ = "CBZ" + CBR = "CBR" + ANY = "ANY" + NONE = "NONE" + + +class ToolArity(StrEnum): + """How many files go in and come out (Single/Multiple In, Single/Multiple Out). A + multi-output tool returns its results zipped, and the caller unpacks them.""" + + SISO = "SISO" + SIMO = "SIMO" + MISO = "MISO" + MIMO = "MIMO" + + +class ToolIOWhen(ApiModel): + """One condition on a request parameter, guarding a :class:`ToolIOCase`.""" + + param: str + matches: list[str] + + +class ToolIOCase(ApiModel): + """An output that applies when every condition in ``when`` holds.""" + + when: list[ToolIOWhen] + produces: ToolFormat + arity: ToolArity + + +class ToolIOSpec(ApiModel): + """What one endpoint accepts and produces.""" + + accepts: list[ToolFormat] + produces: ToolFormat + arity: ToolArity + cases: list[ToolIOCase] = Field(default_factory=list) + + +TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { + ToolEndpoint.CBR_TO_PDF: ToolIOSpec(accepts=[ToolFormat.CBR], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.CBZ_TO_PDF: ToolIOSpec(accepts=[ToolFormat.CBZ], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.EBOOK_TO_PDF: ToolIOSpec(accepts=[ToolFormat.EBOOK], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.EML_TO_PDF: ToolIOSpec(accepts=[ToolFormat.EMAIL], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.FILE_TO_PDF: ToolIOSpec(accepts=[ToolFormat.ANY], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.HTML_TO_PDF: ToolIOSpec( + accepts=[ToolFormat.HTML, ToolFormat.ZIP], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.IMG_TO_PDF: ToolIOSpec(accepts=[ToolFormat.IMAGE], produces=ToolFormat.PDF, arity=ToolArity.MISO), + ToolEndpoint.MARKDOWN_TO_PDF: ToolIOSpec( + accepts=[ToolFormat.MARKDOWN, ToolFormat.ZIP], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.PDF_TO_CBR: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.CBR, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_CBZ: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.CBZ, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_CSV: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.CSV, arity=ToolArity.SIMO), + ToolEndpoint.PDF_TO_EPUB: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.EBOOK, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_HTML: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.ZIP, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_IMG: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.IMAGE, + arity=ToolArity.SIMO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])], + produces=ToolFormat.IMAGE, + arity=ToolArity.SISO, + ) + ], + ), + ToolEndpoint.PDF_TO_MARKDOWN: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.MARKDOWN, arity=ToolArity.SISO + ), + ToolEndpoint.PDF_TO_PDFA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_PRESENTATION: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PPT, arity=ToolArity.SISO + ), + ToolEndpoint.PDF_TO_TEXT: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.TEXT, + arity=ToolArity.SISO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="outputFormat", matches=["rtf"])], produces=ToolFormat.WORD, arity=ToolArity.SISO + ) + ], + ), + ToolEndpoint.PDF_TO_VECTOR: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.IMAGE, + arity=ToolArity.SISO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="outputFormat", matches=["ps"])], + produces=ToolFormat.POSTSCRIPT, + arity=ToolArity.SISO, + ), + ToolIOCase( + when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO + ), + ToolIOCase( + when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO + ), + ], + ), + ToolEndpoint.PDF_TO_WORD: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.WORD, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_XLSX: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.EXCEL, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_XML: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.XML, arity=ToolArity.SISO), + ToolEndpoint.SVG_TO_PDF: ToolIOSpec( + accepts=[ToolFormat.IMAGE], + produces=ToolFormat.PDF, + arity=ToolArity.MIMO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])], + produces=ToolFormat.PDF, + arity=ToolArity.MISO, + ) + ], + ), + ToolEndpoint.URL_TO_PDF: ToolIOSpec(accepts=[ToolFormat.NONE], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.VECTOR_TO_PDF: ToolIOSpec( + accepts=[ToolFormat.POSTSCRIPT], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.BOOKLET_IMPOSITION: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.CROP: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.EDIT_TABLE_OF_CONTENTS: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.EDIT_TEXT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.MERGE_PDFS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.MISO), + ToolEndpoint.MULTI_PAGE_LAYOUT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.PDF_TO_SINGLE_PAGE: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.REARRANGE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REMOVE_IMAGE_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REMOVE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.ROTATE_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.SCALE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.SPLIT_BY_SIZE_OR_COUNT: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO + ), + ToolEndpoint.SPLIT_FOR_POSTER_PRINT: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO + ), + ToolEndpoint.SPLIT_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO), + ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO + ), + ToolEndpoint.SPLIT_PDF_BY_SECTIONS: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO + ), + ToolEndpoint.ADD_COMMENTS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.ADD_PAGE_NUMBERS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.ADD_STAMP: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.AUTO_RENAME: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.AUTO_ROTATE_PDF: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.PDF, + arity=ToolArity.SISO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO + ) + ], + ), + ToolEndpoint.AUTO_SPLIT_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO), + ToolEndpoint.COMPRESS_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.DELETE_ATTACHMENT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.EXTRACT_ATTACHMENTS: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.ZIP, arity=ToolArity.SISO + ), + ToolEndpoint.EXTRACT_IMAGE_SCANS: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, arity=ToolArity.SIMO + ), + ToolEndpoint.EXTRACT_IMAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, arity=ToolArity.SIMO), + ToolEndpoint.FLATTEN: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.OCR_PDF: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.PDF, + arity=ToolArity.SISO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO + ) + ], + ), + ToolEndpoint.REMOVE_BLANKS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO), + ToolEndpoint.RENAME_ATTACHMENT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REPAIR: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REPLACE_INVERT_PDF: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.SCANNER_EFFECT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.UNLOCK_PDF_FORMS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.UPDATE_METADATA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.ADD_PASSWORD: ToolIOSpec( + accepts=[ToolFormat.PDF], + produces=ToolFormat.PDF_ENCRYPTED, + arity=ToolArity.SISO, + cases=[ + ToolIOCase( + when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])], + produces=ToolFormat.PDF, + arity=ToolArity.SISO, + ) + ], + ), + ToolEndpoint.ADD_WATERMARK: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.AUTO_REDACT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REDACT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REDACT_EXECUTE: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REMOVE_CERT_SIGN: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.REMOVE_PASSWORD: ToolIOSpec( + accepts=[ToolFormat.PDF, ToolFormat.PDF_ENCRYPTED], produces=ToolFormat.PDF, arity=ToolArity.SISO + ), + ToolEndpoint.SANITIZE_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.TIMESTAMP_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), +} diff --git a/engine/src/stirling/services/__init__.py b/engine/src/stirling/services/__init__.py index 1db54243f8..4488409142 100644 --- a/engine/src/stirling/services/__init__.py +++ b/engine/src/stirling/services/__init__.py @@ -7,11 +7,15 @@ from .progress import ( set_progress_emitter, ) from .runtime import AppRuntime, build_model_settings, build_runtime +from .tool_io_compat import ToolChainStep, ToolDiagnostic, blocking, validate_tool_chain from .tracking import current_user_id, require_current_user_id, setup_posthog_tracking __all__ = [ "AppRuntime", "ProgressEmitter", + "ToolChainStep", + "ToolDiagnostic", + "blocking", "build_model_settings", "build_runtime", "current_user_id", @@ -20,4 +24,5 @@ __all__ = [ "reset_progress_emitter", "set_progress_emitter", "setup_posthog_tracking", + "validate_tool_chain", ] diff --git a/engine/src/stirling/services/tool_io_compat.py b/engine/src/stirling/services/tool_io_compat.py new file mode 100644 index 0000000000..b2bb5566e9 --- /dev/null +++ b/engine/src/stirling/services/tool_io_compat.py @@ -0,0 +1,216 @@ +"""Whether a chain of tool steps can actually run, decided from the generated tool I/O table +rather than by running it. + +The same rules exist in the backend (``ToolChainValidator``) and the frontend +(``utils/toolIoCompat.ts``). They are duplicated on purpose: the frontend checks a pipeline on +every keystroke, and the engine checks a plan before spending a call per step filling in its +parameters. ``testing/tool-io-cases.json`` runs against all three, so a rule that changes in one +place and not the others fails there. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import Field + +from stirling.models.base import ApiModel +from stirling.models.tool_io import TOOL_IO, ToolArity, ToolFormat, ToolIOSpec +from stirling.models.tool_models import ToolEndpoint + + +class DiagnosticSeverity(StrEnum): + """Only ERROR means the chain cannot run as configured.""" + + ERROR = "ERROR" + WARN = "WARN" + INFO = "INFO" + + +class DiagnosticCode(StrEnum): + """Matching the backend and frontend implementations.""" + + UNDECLARED = "undeclared-operation" + FORMAT_MISMATCH = "format-mismatch" + OUTPUT_UNCERTAIN = "output-uncertain" + SOURCE_MISMATCH = "source-mismatch" + FAN_OUT = "fan-out" + FAN_IN = "fan-in" + + +class ToolDiagnostic(ApiModel): + """One problem, against the step that cannot run.""" + + step_index: int + severity: DiagnosticSeverity + code: DiagnosticCode + message: str + + +class ToolChainStep(ApiModel): + """``parameters`` is only used to resolve an output that depends on one.""" + + operation: ToolEndpoint + parameters: dict[str, object] = Field(default_factory=dict) + + +class ResolvedOutput(ApiModel): + """``certain`` is false when a case reads a parameter we cannot see.""" + + format: ToolFormat + arity: ToolArity + certain: bool + + +def _is_multi_input(arity: ToolArity) -> bool: + return arity in (ToolArity.MISO, ToolArity.MIMO) + + +def _is_multi_output(arity: ToolArity) -> bool: + return arity in (ToolArity.SIMO, ToolArity.MIMO) + + +def _accepts_format(spec: ToolIOSpec, candidate: ToolFormat) -> bool: + return candidate == ToolFormat.ANY or ToolFormat.ANY in spec.accepts or candidate in spec.accepts + + +def _normalise(value: object) -> str: + """Both sides of a condition go through this, so a declaration's casing cannot matter.""" + return "" if value is None else str(value).strip().lower() + + +def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> ResolvedOutput: + """First case whose conditions all hold wins. + + If none match but one reads a parameter we cannot see, the declared output comes back + uncertain: an unseen value might have picked another branch. + """ + saw_unknown_param = False + for rule in spec.cases: + all_hold = True + for condition in rule.when: + if parameters is None or condition.param not in parameters: + saw_unknown_param = True + all_hold = False + continue + normalised = _normalise(parameters[condition.param]) + all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches) + if all_hold: + return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True) + return ResolvedOutput(format=spec.produces, arity=spec.arity, certain=not saw_unknown_param) + + +def validate_tool_chain( + steps: list[ToolChainStep], + *, + source_format: ToolFormat | None = None, + tool_io: dict[ToolEndpoint, ToolIOSpec] | None = None, +) -> list[ToolDiagnostic]: + """Every problem with the chain, against the step that cannot run.""" + table = TOOL_IO if tool_io is None else tool_io + diagnostics: list[ToolDiagnostic] = [] + carried: ResolvedOutput | None = None + + for index, step in enumerate(steps): + spec = table.get(step.operation) + if spec is None: + diagnostics.append( + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.WARN, + code=DiagnosticCode.UNDECLARED, + message=( + f"{step.operation} does not declare what it accepts or produces, " + "so the rest of the chain cannot be checked." + ), + ) + ) + # Nothing is known past an undeclared step. + carried = None + continue + + # Only the first step is handed the pipeline's input. Every later step is handed the + # previous step's output, which is simply unknown once an undeclared step intervened - + # checking it against the input again would judge it on a format it never receives. + if index == 0: + if source_format is not None and not _accepts_format(spec, source_format): + diagnostics.append( + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.ERROR, + code=DiagnosticCode.SOURCE_MISMATCH, + message=(f"{step.operation} accepts {_describe(spec)} but the input is {source_format}."), + ) + ) + elif carried is not None: + diagnostics.extend(_check_transition(index, step, spec, carried)) + carried = resolve_output(spec, step.parameters or None) + + return diagnostics + + +def _check_transition( + index: int, step: ToolChainStep, spec: ToolIOSpec, previous: ResolvedOutput +) -> list[ToolDiagnostic]: + if previous.format == ToolFormat.NONE: + return [ + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.ERROR, + code=DiagnosticCode.FORMAT_MISMATCH, + message=( + f"The previous step returns a report rather than a file, so {step.operation} has nothing to run on." + ), + ) + ] + + if not _accepts_format(spec, previous.format): + message = f"{step.operation} accepts {_describe(spec)} but the previous step produces {previous.format}." + # Unresolved output: may yet be fine once the step is configured. + return [ + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.ERROR if previous.certain else DiagnosticSeverity.WARN, + code=DiagnosticCode.FORMAT_MISMATCH if previous.certain else DiagnosticCode.OUTPUT_UNCERTAIN, + message=message, + ) + ] + + if not previous.certain: + return [ + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.WARN, + code=DiagnosticCode.OUTPUT_UNCERTAIN, + message=( + "The previous step's output depends on how it is configured, " + f"so {step.operation} may not be able to run." + ), + ) + ] + + if _is_multi_output(previous.arity): + fan_in = _is_multi_input(spec.arity) + return [ + ToolDiagnostic( + step_index=index, + severity=DiagnosticSeverity.INFO, + code=DiagnosticCode.FAN_IN if fan_in else DiagnosticCode.FAN_OUT, + message=( + f"{step.operation} combines every file the previous step produced." + if fan_in + else f"{step.operation} runs once for each file the previous step produced." + ), + ) + ] + + return [] + + +def _describe(spec: ToolIOSpec) -> str: + return " or ".join(sorted(f.value for f in spec.accepts)) + + +def blocking(diagnostics: list[ToolDiagnostic]) -> list[ToolDiagnostic]: + """The diagnostics that mean the chain cannot run.""" + return [d for d in diagnostics if d.severity == DiagnosticSeverity.ERROR] diff --git a/engine/tests/test_pdf_edit_agent.py b/engine/tests/test_pdf_edit_agent.py index 0ba5eca6c9..15ca39369c 100644 --- a/engine/tests/test_pdf_edit_agent.py +++ b/engine/tests/test_pdf_edit_agent.py @@ -28,6 +28,7 @@ from stirling.models.tool_models import ( EditTextParams, FlattenParams, RotatePdfParams, + SplitPagesParams, ToolEndpoint, ) from stirling.services.runtime import AppRuntime @@ -76,9 +77,13 @@ class StubPdfEditAgent(PdfEditAgent): runtime: AppRuntime, selection: PdfEditPlanOutput, parameter_selector: RecordingParameterSelector | PdfEditParameterSelector | None = None, + later_selections: list[PdfEditPlanOutput] | None = None, ) -> None: super().__init__(runtime) self.selection = selection + # Selections handed out on retry, in order, so a test can drive the repair loop. + self.later_selections = list(later_selections or []) + self.repair_notes: list[str] = [] if parameter_selector is not None: self.parameter_selector = parameter_selector @@ -96,7 +101,11 @@ class StubPdfEditAgent(PdfEditAgent): unavailable_operations: Iterable[ToolEndpoint], *, allow_need_content: bool = True, + repair_note: str = "", ) -> PdfEditPlanOutput: + self.repair_notes.append(repair_note) + if repair_note and self.later_selections: + return self.later_selections.pop(0) return self.selection @@ -128,6 +137,121 @@ async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> Non assert isinstance(response.steps[1].parameters, FlattenParams) +_ANY_SELECTION = PdfEditPlanSelection(operations=[ToolEndpoint.ROTATE_PDF], summary="s", rationale="r") + + +def test_selection_prompt_says_nothing_about_output_formats(runtime: AppRuntime) -> None: + # Compatibility is only raised once a plan has actually failed, so the operation list stays + # about what each tool does. Leaking format hints here re-inflates an already large prompt. + agent = StubPdfEditAgent(runtime, _ANY_SELECTION) + prompt = agent._build_selection_prompt(PdfEditRequest(user_message="anything", files=[]), list(OPERATIONS), []) + assert "outputs:" not in prompt + assert "IMAGE (several files)" not in prompt + + +def test_repair_prompt_offers_reorder_or_telling_the_user(runtime: AppRuntime) -> None: + # The model decides which: it has the user's intent, and a reorder that changes the result + # is worse than saying it cannot be done. + agent = StubPdfEditAgent(runtime, _ANY_SELECTION) + prompt = agent._build_selection_prompt( + PdfEditRequest(user_message="anything", files=[]), + list(OPERATIONS), + [], + "step 3 (SANITIZE_PDF) accepts PDF but the previous step produces IMAGE.", + ) + assert "SANITIZE_PDF" in prompt + assert "different order" in prompt + assert "cannot_do" in prompt + + +@pytest.mark.anyio +async def test_pdf_edit_agent_retries_a_plan_whose_steps_cannot_chain(runtime: AppRuntime) -> None: + # Extracting images emits images, which rotate cannot take. The agent should be told exactly + # that and get one chance to produce something workable rather than shipping a plan that + # would fail part-way through execution. + agent = StubPdfEditAgent( + runtime, + PdfEditPlanSelection( + operations=[ToolEndpoint.EXTRACT_IMAGES, ToolEndpoint.ROTATE_PDF], + summary="Extract the images, then rotate.", + rationale="Initial attempt.", + ), + later_selections=[ + PdfEditPlanSelection( + operations=[ToolEndpoint.ROTATE_PDF], + summary="Rotate the PDF.", + rationale="Repaired attempt.", + ) + ], + parameter_selector=RecordingParameterSelector(), + ) + + response = await agent.handle( + PdfEditRequest( + user_message="Pull out the images and rotate them.", + files=[AiFile(id=FileId("scan-id"), name="scan.pdf")], + ) + ) + + assert isinstance(response, EditPlanResponse) + assert [step.tool for step in response.steps] == [ToolEndpoint.ROTATE_PDF] + # First attempt gets no note; the retry is told which transition failed. + assert agent.repair_notes[0] == "" + assert "ROTATE_PDF" in agent.repair_notes[1] + + +@pytest.mark.anyio +async def test_pdf_edit_agent_gives_up_when_the_repaired_plan_still_cannot_chain( + runtime: AppRuntime, +) -> None: + agent = StubPdfEditAgent( + runtime, + PdfEditPlanSelection( + operations=[ToolEndpoint.EXTRACT_IMAGES, ToolEndpoint.ROTATE_PDF], + summary="Extract the images, then rotate.", + rationale="Initial attempt.", + ), + ) + + response = await agent.handle( + PdfEditRequest( + user_message="Pull out the images and rotate them.", + files=[AiFile(id=FileId("scan-id"), name="scan.pdf")], + ) + ) + + assert isinstance(response, EditCannotDoResponse) + assert "No workable order" in response.reason + # Exactly one retry, not an unbounded loop. + assert len(agent.repair_notes) == 2 + + +@pytest.mark.anyio +async def test_pdf_edit_agent_accepts_a_chain_that_lines_up(runtime: AppRuntime) -> None: + agent = StubPdfEditAgent( + runtime, + PdfEditPlanSelection( + operations=[ToolEndpoint.SPLIT_PAGES, ToolEndpoint.ROTATE_PDF], + summary="Split then rotate.", + rationale="Splitting fans out; rotate runs per file.", + ), + parameter_selector=RecordingParameterSelector( + [SplitPagesParams(page_numbers="all"), RotatePdfParams(angle=Angle(90))] + ), + ) + + response = await agent.handle( + PdfEditRequest( + user_message="Split the pages and rotate each one.", + files=[AiFile(id=FileId("scan-id"), name="scan.pdf")], + ) + ) + + # A fan-out is information, not a problem, so no retry. + assert isinstance(response, EditPlanResponse) + assert agent.repair_notes == [""] + + @pytest.mark.anyio async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtime: AppRuntime) -> None: parameter_selector = RecordingParameterSelector() diff --git a/engine/tests/test_tool_io_compat.py b/engine/tests/test_tool_io_compat.py new file mode 100644 index 0000000000..18f9c79801 --- /dev/null +++ b/engine/tests/test_tool_io_compat.py @@ -0,0 +1,59 @@ +"""The shared cases in ``testing/tool-io-cases.json``, which all three implementations run.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from stirling.models.tool_io import ToolFormat, ToolIOSpec +from stirling.models.tool_models import ToolEndpoint +from stirling.services.tool_io_compat import ( + ToolChainStep, + ToolDiagnostic, + validate_tool_chain, +) + + +def _cases_file() -> Path: + """Shared with the backend and frontend, so it lives at the repo root.""" + for parent in Path(__file__).resolve().parents: + candidate = parent / "testing" / "tool-io-cases.json" + if candidate.exists(): + return candidate + raise RuntimeError("testing/tool-io-cases.json not found above the test directory") + + +_DATA: dict[str, Any] = json.loads(_cases_file().read_text()) +_SPECS: dict[str, ToolIOSpec] = {name: ToolIOSpec.model_validate(spec) for name, spec in _DATA["specs"].items()} +_ENDPOINTS: list[ToolEndpoint] = list(ToolEndpoint) + + +def _summarise(diagnostics: list[ToolDiagnostic]) -> list[str]: + """Messages are free text, so compare only the contractual parts.""" + return [f"{d.step_index}:{d.severity}:{d.code}" for d in diagnostics] + + +@pytest.mark.parametrize("case", _DATA["cases"], ids=lambda c: c["name"]) +def test_shared_cases(case: dict[str, Any]) -> None: + table: dict[ToolEndpoint, ToolIOSpec] = {} + steps: list[ToolChainStep] = [] + for index, step in enumerate(case["steps"]): + # Distinct endpoints so the same spec can appear twice in a chain. Which ones is + # irrelevant: the case supplies its own table, these are just keys. + operation = _ENDPOINTS[index] + if step.get("spec") is not None: + table[operation] = _SPECS[step["spec"]] + steps.append(ToolChainStep(operation=operation, parameters=step.get("parameters") or {})) + + source = case.get("sourceFormat") + actual = validate_tool_chain( + steps, + source_format=ToolFormat(source) if source else None, + tool_io=table, + ) + + expected = [f"{e['stepIndex']}:{e['severity']}:{e['code']}" for e in case["expected"]] + assert _summarise(actual) == expected, [d.message for d in actual] diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 3b94a878ac..25a2e4bafa 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7740,6 +7740,7 @@ newPipeline = "New pipeline" [portal.pipelines.builder] addStep = "Add tool" back = "Back to pipelines" +cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" @@ -7759,6 +7760,7 @@ searchTools = "Search tools" selectToolBody = "Add a tool to build your pipeline." selectToolTitle = "No tools yet" sendToSystem = "Send to another system" +stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." toolSettings = "Tool settings" unknownStep = "Unrecognized operation, kept as-is." @@ -7767,6 +7769,14 @@ unsavedTitle = "Unsaved changes" uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." usesDefaults = "Runs with default settings" +[portal.pipelines.builder.diagnostic] +fan-in = "Combines every file from the previous step" +fan-out = "Runs once per file from the previous step" +format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}" +output-uncertain = "May not run: the previous step's output depends on how it's set up" +source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}" +undeclared-operation = "Can't check what this step accepts" + [portal.pipelines.composer] addTool = "Add tool" cancel = "Cancel" @@ -10631,6 +10641,32 @@ selectFilesHint = "Select files in Active Files to run this tool" singleFileScope = "Only applying to: {{fileName}}" viewerMode = "Switch to the file editor to add multiple files." +[toolFormat] +ANY = "any file" +CBR = "a comic book archive (CBR)" +CBZ = "a comic book archive (CBZ)" +CSV = "a CSV file" +EBOOK = "an ebook" +EMAIL = "an email" +EXCEL = "a spreadsheet" +HTML = "a web page" +IMAGE = "images" +JAVASCRIPT = "a JavaScript file" +JSON = "a JSON file" +MARKDOWN = "a Markdown file" +NONE = "no file" +PCL = "a PCL print file" +PDF = "a PDF" +PDF_ENCRYPTED = "a password-protected PDF" +POSTSCRIPT = "a PostScript file" +PPT = "a presentation" +TEXT = "a text file" +VIDEO = "a video" +WORD = "a Word document" +XML = "an XML file" +XPS = "an XPS document" +ZIP = "a ZIP archive" + [toolPanel] alpha = "Alpha" backToAllTools = "Back to all tools" diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index f12572620a..a805f2dfa3 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -1,7 +1,9 @@ /** - * Generates the committed frontend tool API types (toolApiTypes.ts) from the - * Java backend's OpenAPI spec, so the frontend's request shapes stay in step - * with the backend. + * Generates the committed frontend files derived from the Java OpenAPI spec: + * toolApiTypes.ts (each tool's request shape) and toolIO.ts (what it accepts and + * produces, from `@ToolIO` via the `x-stirling-io` extension). + * + * One pass over one spec, so the two cannot drift apart. */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; @@ -10,14 +12,20 @@ import { parseArgs } from "node:util"; import { compile, type JSONSchema } from "json-schema-to-typescript"; import * as prettier from "prettier"; -// The API namespaces whose endpoints are real, callable tools. `/api/v1/filter/` -// (pipeline-only) and `/api/v1/ai/tools/` (not in the spec) are intentionally -// excluded. Extend this list when other namespaces become tools. +// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/` +// is absent from the spec, so it cannot appear here. Extend this list when other +// namespaces become tools. +// +// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a +// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O +// table, so leaving them out would stop a chain being checked past such a step. const ALLOWED_PATH_PREFIXES = [ "/api/v1/general/", "/api/v1/misc/", "/api/v1/security/", "/api/v1/convert/", + "/api/v1/filter/", + "/api/v1/integration/", ]; // File plumbing, not user parameters: `fileInput` is the uploaded document and @@ -38,12 +46,21 @@ const FILE_WRAPPER_COMPONENTS = new Set([ const COMPONENT_REF_PREFIX = "#/components/schemas/"; -const FILE_HEADER = [ - "// AUTO-GENERATED FILE. DO NOT EDIT.", - "// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec", - "// (SwaggerDoc.json). Regenerate with: task frontend:tool-models", +const IO_EXTENSION = "x-stirling-io"; +const IO_VOCABULARY_EXTENSION = "x-stirling-io-vocabulary"; + +function fileHeader(...extra: string[]): string { + return [ + "// AUTO-GENERATED FILE. DO NOT EDIT.", + "// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec", + "// (SwaggerDoc.json). Regenerate with: task frontend:tool-models", + ...extra, + ].join("\n"); +} + +const FILE_HEADER = fileHeader( "// Tools that take only a file input have no parameters; their model is Record.", -].join("\n"); +); type Json = Record; @@ -170,23 +187,164 @@ function computeRequired(schema: Json, properties: Json): string[] { }); } +/** + * The `x-stirling-io` declaration for every endpoint that carries one, path-sorted. + * + * Restricted to `endpoints`, the paths that become {@link ToolEndpoint}: the table is typed by + * that union, so a declaration outside it would not type-check. `dropped` is reported rather + * than discarded silently, since a tool losing its declaration this way is invisible otherwise. + */ +function collectToolIO( + paths: Json, + endpoints: Set, +): { table: Record; dropped: string[] } { + const table: Record = {}; + const dropped: string[] = []; + for (const path of Object.keys(paths).sort()) { + const pathItem = paths[path]; + if (!isObject(pathItem)) continue; + for (const operation of Object.values(pathItem)) { + if (isObject(operation) && operation[IO_EXTENSION]) { + if (endpoints.has(path)) table[path] = operation[IO_EXTENSION]; + else dropped.push(path); + } + } + } + return { table, dropped }; +} + +async function renderToolIO( + spec: Json, + table: Record, + outputPath: string, +): Promise { + if (Object.keys(table).length === 0) { + throw new Error( + `No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`, + ); + } + // Published separately: deriving the unions from the declarations present would shrink them + // whenever an endpoint is disabled in a build. + const vocabulary = spec[IO_VOCABULARY_EXTENSION]; + if (!isObject(vocabulary)) { + throw new Error( + `No ${IO_VOCABULARY_EXTENSION} in the spec; regenerate it from a current backend.`, + ); + } + const union = (values: string[]) => + values.map((v) => JSON.stringify(v)).join(" | "); + const formats = vocabulary.formats as string[]; + + const body = `${fileHeader()} + +/** + * What each tool endpoint accepts and produces, so a chain can be checked while it is being + * edited rather than by running it. Declared in Java with \`@ToolIO\`; see + * \`utils/toolIOCompat\` for the compatibility rules that read this table. + */ + +import { type ToolEndpoint } from "@app/types/toolApiTypes"; + +/** The kind of file a tool consumes or produces. \`ANY\` accepts or produces anything; \`NONE\` means no file at all. */ +export type ToolFormat = ${union(formats)}; + +/** Every format, for iteration. */ +export const TOOL_FORMATS = ${JSON.stringify(formats)} as const satisfies readonly ToolFormat[]; + +/** How many files go in and come out. A multi-output tool returns its results zipped, and the caller unpacks them. */ +export type ToolArity = ${union(vocabulary.arities as string[])}; + +/** One condition on a request parameter, guarding a {@link ToolIOCase}. */ +export interface ToolIOWhen { + param: string; + matches: string[]; +} + +/** An output that applies when every condition in \`when\` holds. */ +export interface ToolIOCase { + when: ToolIOWhen[]; + produces: ToolFormat; + arity: ToolArity; +} + +/** What one endpoint accepts and produces. */ +export interface ToolIOSpec { + accepts: ToolFormat[]; + produces: ToolFormat; + arity: ToolArity; + cases?: ToolIOCase[]; +} + +/** + * What each endpoint accepts and produces, keyed by {@link ToolEndpoint} so the paths live in + * exactly one place. Partial: endpoints that manage a session, a device or a stored resource + * rather than transform a document declare nothing. + */ +export type ToolIOTable = Partial>; + +export const TOOL_IO: ToolIOTable = ${JSON.stringify(table)}; + +/** The declaration for an endpoint, or undefined when it declares none. */ +export function toolIOFor( + operation: string, + table: ToolIOTable = TOOL_IO, +): ToolIOSpec | undefined { + // The table's own keys are the valid set, so a hasOwn guard is the narrowing. + return Object.hasOwn(table, operation) + ? table[operation as ToolEndpoint] + : undefined; +} +`; + + const prettierConfig = await prettier.resolveConfig(outputPath); + return prettier.format(body, { ...prettierConfig, parser: "typescript" }); +} + +/** In check mode, fail when the committed file is out of date. */ +function writeOrCheck( + outputPath: string, + formatted: string, + check: boolean, + task: string, +): void { + if (check) { + let current = ""; + try { + current = readFileSync(outputPath, "utf-8"); + } catch { + // Missing file counts as out of date. + } + if (current !== formatted) { + throw new Error( + `${outputPath} is out of date. Run '${task}' and commit the result.`, + ); + } + return; + } + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, formatted, "utf-8"); +} + async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: "string" }, output: { type: "string" }, + "io-output": { type: "string" }, check: { type: "boolean", default: false }, }, }); - if (!values.spec || !values.output) { + if (!values.spec || !values.output || !values["io-output"]) { throw new Error( - "Usage: generate-tool-api-types.mts --spec --output [--check]", + "Usage: generate-tool-api-types.mts --spec --output --io-output [--check]", ); } const specPath = resolve(values.spec); const outputPath = resolve(values.output); + const ioOutputPath = resolve(values["io-output"]); const spec = JSON.parse(readFileSync(specPath, "utf-8")) as Json; + const paths = isObject(spec.paths) ? spec.paths : {}; const components = isObject(spec.components) && isObject(spec.components.schemas) @@ -267,6 +425,25 @@ async function main(): Promise { tools.push({ path, className }); } + const { table: ioDeclarations, dropped } = collectToolIO( + paths, + new Set(tools.map((tool) => tool.path)), + ); + if (dropped.length > 0) { + console.warn( + `Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`, + ); + } + writeOrCheck( + ioOutputPath, + await renderToolIO(spec, ioDeclarations, ioOutputPath), + values.check ?? false, + "task frontend:tool-models", + ); + console.log( + `${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`, + ); + // Transitively inline every referenced component into `definitions`, rewriting its refs too. const queue = [...pendingComponents]; while (queue.length > 0) { @@ -368,26 +545,11 @@ async function compileAndWrite( parser: "typescript", }); - if (check) { - let current = ""; - try { - current = readFileSync(outputPath, "utf-8"); - } catch { - // Missing file counts as out of date. - } - if (current !== formatted) { - throw new Error( - `${outputPath} is out of date. Run 'task frontend:tool-models' and commit the result.`, - ); - } - console.log(`Up to date: ${tools.length} tool endpoints.`); - return; - } - - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, formatted, "utf-8"); - console.log(`Generated ${tools.length} tool endpoints -> ${outputPath}`); - if (skipped.length > 0) { + writeOrCheck(outputPath, formatted, check, "task frontend:tool-models"); + console.log( + `${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`, + ); + if (!check && skipped.length > 0) { console.log( `Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`, ); diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index af7339d936..bf498eb450 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -276,6 +276,16 @@ export interface BookletImpositionRequest { */ spineLocation?: "LEFT" | "RIGHT"; } +export interface ContainsTextRequest { + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; + /** + * The text to check for + */ + text?: string; +} export interface ConvertCbrToPdfRequest { /** * Optimize the output PDF for ebook reading using Ghostscript @@ -495,6 +505,16 @@ export interface ExtractImageScansRequest { */ tolerance?: number; } +export interface FileSizeRequest { + /** + * The comparison type, accepts Greater, Equal, Less than + */ + comparator: "Greater" | "Equal" | "Less"; + /** + * Size of the file in bytes + */ + fileSize?: number; +} export interface FlattenRequest { /** * True to flatten only the forms, false to flatten full PDF (Convert page to image) @@ -517,6 +537,33 @@ export interface HTMLToPdfRequest { */ zoom?: number; } +export interface IntegrationExternalApiCallRequest { + bodyMode?: string; + bodyTemplate?: string; + connectionId: string; + fields?: string; + fileFieldName?: string; + headers?: string; + includeContext?: boolean; + includeFile?: boolean; + method?: string; + path?: string; + requireTrue?: string; + responseMode?: string; + responseSelect?: string; + resultUrlHeader?: string; + resultUrlPath?: string; +} +export interface IntegrationPurviewApplyLabelRequest { + connectionId: string; + contentBits?: number; + labelId: string; + labelName?: string; + method?: string; +} +export interface IntegrationPurviewReadLabelRequest { + connectionId: string; +} export type ListAttachmentsRequest = Record; export interface ManualRedactPdfRequest { /** @@ -772,6 +819,16 @@ export interface OverlayPdfsRequest { */ overlayPosition: 0 | 1; } +export interface PDFComparisonAndCount { + /** + * The comparison type, accepts Greater, Equal, Less than + */ + comparator: "Greater" | "Equal" | "Less"; + /** + * Count + */ + pageCount?: number; +} export interface PDFExtractImagesRequest { /** * The output image format e.g., 'png', 'jpeg', or 'gif' @@ -791,6 +848,35 @@ export interface PDFWithPageNums { */ pageNumbers?: string; } +export interface PageRotationRequest { + /** + * The comparison type, accepts Greater, Equal, Less than + */ + comparator: "Greater" | "Equal" | "Less"; + /** + * Rotation in degrees + */ + rotation?: number; +} +export interface PageSizeRequest { + /** + * The comparison type, accepts Greater, Equal, Less than + */ + comparator: "Greater" | "Equal" | "Less"; + /** + * Standard Page Size + */ + standardPageSize?: + | "A0" + | "A1" + | "A2" + | "A3" + | "A4" + | "A5" + | "A6" + | "LETTER" + | "LEGAL"; +} export interface PdfToPdfARequest { /** * The output format type (PDF/A or PDF/X) @@ -1383,6 +1469,12 @@ export type ToolEndpoint = | "/api/v1/convert/text-editor/pdf" | "/api/v1/convert/url/pdf" | "/api/v1/convert/vector/pdf" + | "/api/v1/filter/filter-contains-image" + | "/api/v1/filter/filter-contains-text" + | "/api/v1/filter/filter-file-size" + | "/api/v1/filter/filter-page-count" + | "/api/v1/filter/filter-page-rotation" + | "/api/v1/filter/filter-page-size" | "/api/v1/general/booklet-imposition" | "/api/v1/general/crop" | "/api/v1/general/edit-table-of-contents" @@ -1402,6 +1494,9 @@ export type ToolEndpoint = | "/api/v1/general/split-pages" | "/api/v1/general/split-pdf-by-chapters" | "/api/v1/general/split-pdf-by-sections" + | "/api/v1/integration/external-api-call" + | "/api/v1/integration/purview-apply-label" + | "/api/v1/integration/purview-read-label" | "/api/v1/misc/add-attachments" | "/api/v1/misc/add-comments" | "/api/v1/misc/add-image" @@ -1474,6 +1569,12 @@ export interface ToolApiParams { "/api/v1/convert/text-editor/pdf": GeneralFile; "/api/v1/convert/url/pdf": UrlToPdfRequest; "/api/v1/convert/vector/pdf": PdfVectorExportRequest; + "/api/v1/filter/filter-contains-image": PDFWithPageNums; + "/api/v1/filter/filter-contains-text": ContainsTextRequest; + "/api/v1/filter/filter-file-size": FileSizeRequest; + "/api/v1/filter/filter-page-count": PDFComparisonAndCount; + "/api/v1/filter/filter-page-rotation": PageRotationRequest; + "/api/v1/filter/filter-page-size": PageSizeRequest; "/api/v1/general/booklet-imposition": BookletImpositionRequest; "/api/v1/general/crop": CropPdfForm; "/api/v1/general/edit-table-of-contents": EditTableOfContentsRequest; @@ -1493,6 +1594,9 @@ export interface ToolApiParams { "/api/v1/general/split-pages": SplitPagesRequest; "/api/v1/general/split-pdf-by-chapters": SplitPdfByChaptersRequest; "/api/v1/general/split-pdf-by-sections": SplitPdfBySectionsRequest; + "/api/v1/integration/external-api-call": IntegrationExternalApiCallRequest; + "/api/v1/integration/purview-apply-label": IntegrationPurviewApplyLabelRequest; + "/api/v1/integration/purview-read-label": IntegrationPurviewReadLabelRequest; "/api/v1/misc/add-attachments": AddAttachmentRequest; "/api/v1/misc/add-comments": AddCommentsRequest; "/api/v1/misc/add-image": OverlayImageRequest; @@ -1566,6 +1670,12 @@ export const TOOL_ENDPOINTS = [ "/api/v1/convert/text-editor/pdf", "/api/v1/convert/url/pdf", "/api/v1/convert/vector/pdf", + "/api/v1/filter/filter-contains-image", + "/api/v1/filter/filter-contains-text", + "/api/v1/filter/filter-file-size", + "/api/v1/filter/filter-page-count", + "/api/v1/filter/filter-page-rotation", + "/api/v1/filter/filter-page-size", "/api/v1/general/booklet-imposition", "/api/v1/general/crop", "/api/v1/general/edit-table-of-contents", @@ -1585,6 +1695,9 @@ export const TOOL_ENDPOINTS = [ "/api/v1/general/split-pages", "/api/v1/general/split-pdf-by-chapters", "/api/v1/general/split-pdf-by-sections", + "/api/v1/integration/external-api-call", + "/api/v1/integration/purview-apply-label", + "/api/v1/integration/purview-read-label", "/api/v1/misc/add-attachments", "/api/v1/misc/add-comments", "/api/v1/misc/add-image", diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts new file mode 100644 index 0000000000..f8a034dcee --- /dev/null +++ b/frontend/editor/src/core/types/toolIO.ts @@ -0,0 +1,616 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec +// (SwaggerDoc.json). Regenerate with: task frontend:tool-models + +/** + * What each tool endpoint accepts and produces, so a chain can be checked while it is being + * edited rather than by running it. Declared in Java with `@ToolIO`; see + * `utils/toolIOCompat` for the compatibility rules that read this table. + */ + +import { type ToolEndpoint } from "@app/types/toolApiTypes"; + +/** The kind of file a tool consumes or produces. `ANY` accepts or produces anything; `NONE` means no file at all. */ +export type ToolFormat = + | "PDF" + | "PDF_ENCRYPTED" + | "IMAGE" + | "ZIP" + | "WORD" + | "PPT" + | "EXCEL" + | "CSV" + | "HTML" + | "XML" + | "JSON" + | "TEXT" + | "MARKDOWN" + | "JAVASCRIPT" + | "EBOOK" + | "EMAIL" + | "POSTSCRIPT" + | "PCL" + | "XPS" + | "VIDEO" + | "CBZ" + | "CBR" + | "ANY" + | "NONE"; + +/** Every format, for iteration. */ +export const TOOL_FORMATS = [ + "PDF", + "PDF_ENCRYPTED", + "IMAGE", + "ZIP", + "WORD", + "PPT", + "EXCEL", + "CSV", + "HTML", + "XML", + "JSON", + "TEXT", + "MARKDOWN", + "JAVASCRIPT", + "EBOOK", + "EMAIL", + "POSTSCRIPT", + "PCL", + "XPS", + "VIDEO", + "CBZ", + "CBR", + "ANY", + "NONE", +] as const satisfies readonly ToolFormat[]; + +/** How many files go in and come out. A multi-output tool returns its results zipped, and the caller unpacks them. */ +export type ToolArity = "SISO" | "SIMO" | "MISO" | "MIMO"; + +/** One condition on a request parameter, guarding a {@link ToolIOCase}. */ +export interface ToolIOWhen { + param: string; + matches: string[]; +} + +/** An output that applies when every condition in `when` holds. */ +export interface ToolIOCase { + when: ToolIOWhen[]; + produces: ToolFormat; + arity: ToolArity; +} + +/** What one endpoint accepts and produces. */ +export interface ToolIOSpec { + accepts: ToolFormat[]; + produces: ToolFormat; + arity: ToolArity; + cases?: ToolIOCase[]; +} + +/** + * What each endpoint accepts and produces, keyed by {@link ToolEndpoint} so the paths live in + * exactly one place. Partial: endpoints that manage a session, a device or a stored resource + * rather than transform a document declare nothing. + */ +export type ToolIOTable = Partial>; + +export const TOOL_IO: ToolIOTable = { + "/api/v1/convert/cbr/pdf": { + accepts: ["CBR"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/cbz/pdf": { + accepts: ["CBZ"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/ebook/pdf": { + accepts: ["EBOOK"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/eml/pdf": { + accepts: ["EMAIL"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/file/pdf": { + accepts: ["ANY"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/html/pdf": { + accepts: ["HTML", "ZIP"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/img/pdf": { + accepts: ["IMAGE"], + produces: "PDF", + arity: "MISO", + }, + "/api/v1/convert/markdown/pdf": { + accepts: ["MARKDOWN", "ZIP"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/pdf/cbr": { + accepts: ["PDF"], + produces: "CBR", + arity: "SISO", + }, + "/api/v1/convert/pdf/cbz": { + accepts: ["PDF"], + produces: "CBZ", + arity: "SISO", + }, + "/api/v1/convert/pdf/csv": { + accepts: ["PDF"], + produces: "CSV", + arity: "SIMO", + }, + "/api/v1/convert/pdf/epub": { + accepts: ["PDF"], + produces: "EBOOK", + arity: "SISO", + }, + "/api/v1/convert/pdf/html": { + accepts: ["PDF"], + produces: "ZIP", + arity: "SISO", + }, + "/api/v1/convert/pdf/img": { + accepts: ["PDF"], + produces: "IMAGE", + arity: "SIMO", + cases: [ + { + when: [{ param: "singleOrMultiple", matches: ["single"] }], + produces: "IMAGE", + arity: "SISO", + }, + ], + }, + "/api/v1/convert/pdf/markdown": { + accepts: ["PDF"], + produces: "MARKDOWN", + arity: "SISO", + }, + "/api/v1/convert/pdf/pdfa": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/pdf/presentation": { + accepts: ["PDF"], + produces: "PPT", + arity: "SISO", + }, + "/api/v1/convert/pdf/text": { + accepts: ["PDF"], + produces: "TEXT", + arity: "SISO", + cases: [ + { + when: [{ param: "outputFormat", matches: ["rtf"] }], + produces: "WORD", + arity: "SISO", + }, + ], + }, + "/api/v1/convert/pdf/vector": { + accepts: ["PDF"], + produces: "IMAGE", + arity: "SISO", + cases: [ + { + when: [{ param: "outputFormat", matches: ["ps"] }], + produces: "POSTSCRIPT", + arity: "SISO", + }, + { + when: [{ param: "outputFormat", matches: ["pcl"] }], + produces: "PCL", + arity: "SISO", + }, + { + when: [{ param: "outputFormat", matches: ["xps"] }], + produces: "XPS", + arity: "SISO", + }, + ], + }, + "/api/v1/convert/pdf/word": { + accepts: ["PDF"], + produces: "WORD", + arity: "SISO", + }, + "/api/v1/convert/pdf/xlsx": { + accepts: ["PDF"], + produces: "EXCEL", + arity: "SISO", + }, + "/api/v1/convert/pdf/xml": { + accepts: ["PDF"], + produces: "XML", + arity: "SISO", + }, + "/api/v1/convert/svg/pdf": { + accepts: ["IMAGE"], + produces: "PDF", + arity: "MIMO", + cases: [ + { + when: [{ param: "combineIntoSinglePdf", matches: ["true"] }], + produces: "PDF", + arity: "MISO", + }, + ], + }, + "/api/v1/convert/url/pdf": { + accepts: ["NONE"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/convert/vector/pdf": { + accepts: ["POSTSCRIPT"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-contains-image": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-contains-text": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-file-size": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-page-count": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-page-rotation": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/filter/filter-page-size": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/booklet-imposition": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/crop": { accepts: ["PDF"], produces: "PDF", arity: "SISO" }, + "/api/v1/general/edit-table-of-contents": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/edit-text": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/extract-bookmarks": { + accepts: ["PDF"], + produces: "JSON", + arity: "SISO", + }, + "/api/v1/general/merge-pdfs": { + accepts: ["PDF"], + produces: "PDF", + arity: "MISO", + }, + "/api/v1/general/multi-page-layout": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/overlay-pdfs": { + accepts: ["PDF"], + produces: "PDF", + arity: "MISO", + }, + "/api/v1/general/pdf-to-single-page": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/rearrange-pages": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/remove-image-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/remove-pages": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/rotate-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/scale-pages": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/general/split-by-size-or-count": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/general/split-for-poster-print": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/general/split-pages": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/general/split-pdf-by-chapters": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/general/split-pdf-by-sections": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/integration/external-api-call": { + accepts: ["ANY"], + produces: "ANY", + arity: "SISO", + }, + "/api/v1/integration/purview-apply-label": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/integration/purview-read-label": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/add-attachments": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/add-comments": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/add-image": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/add-page-numbers": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/add-stamp": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/auto-rename": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/auto-rotate-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + cases: [ + { + when: [{ param: "dryRun", matches: ["true"] }], + produces: "JSON", + arity: "SISO", + }, + ], + }, + "/api/v1/misc/auto-split-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/misc/compress-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/decompress-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/delete-attachment": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/extract-attachments": { + accepts: ["PDF"], + produces: "ZIP", + arity: "SISO", + }, + "/api/v1/misc/extract-image-scans": { + accepts: ["PDF"], + produces: "IMAGE", + arity: "SIMO", + }, + "/api/v1/misc/extract-images": { + accepts: ["PDF"], + produces: "IMAGE", + arity: "SIMO", + }, + "/api/v1/misc/flatten": { accepts: ["PDF"], produces: "PDF", arity: "SISO" }, + "/api/v1/misc/list-attachments": { + accepts: ["PDF"], + produces: "JSON", + arity: "SISO", + }, + "/api/v1/misc/ocr-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + cases: [ + { + when: [{ param: "sidecar", matches: ["true"] }], + produces: "ZIP", + arity: "SISO", + }, + ], + }, + "/api/v1/misc/remove-blanks": { + accepts: ["PDF"], + produces: "PDF", + arity: "SIMO", + }, + "/api/v1/misc/rename-attachment": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/repair": { accepts: ["PDF"], produces: "PDF", arity: "SISO" }, + "/api/v1/misc/replace-invert-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/scanner-effect": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/show-javascript": { + accepts: ["PDF"], + produces: "JAVASCRIPT", + arity: "SISO", + }, + "/api/v1/misc/unlock-pdf-forms": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/misc/update-metadata": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/add-password": { + accepts: ["PDF"], + produces: "PDF_ENCRYPTED", + arity: "SISO", + cases: [ + { + when: [ + { param: "password", matches: [""] }, + { param: "ownerPassword", matches: [""] }, + ], + produces: "PDF", + arity: "SISO", + }, + ], + }, + "/api/v1/security/add-watermark": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/auto-redact": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/cert-sign": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/get-info-on-pdf": { + accepts: ["PDF"], + produces: "JSON", + arity: "SISO", + }, + "/api/v1/security/redact": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/redact-execute": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/remove-cert-sign": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/remove-password": { + accepts: ["PDF", "PDF_ENCRYPTED"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/sanitize-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/timestamp-pdf": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, + "/api/v1/security/validate-signature": { + accepts: ["PDF"], + produces: "JSON", + arity: "SISO", + }, + "/api/v1/security/verify-pdf": { + accepts: ["PDF"], + produces: "JSON", + arity: "SISO", + }, +}; + +/** The declaration for an endpoint, or undefined when it declares none. */ +export function toolIOFor( + operation: string, + table: ToolIOTable = TOOL_IO, +): ToolIOSpec | undefined { + // The table's own keys are the valid set, so a hasOwn guard is the narrowing. + return Object.hasOwn(table, operation) + ? table[operation as ToolEndpoint] + : undefined; +} diff --git a/frontend/editor/src/core/utils/toolIOCompat.test.ts b/frontend/editor/src/core/utils/toolIOCompat.test.ts new file mode 100644 index 0000000000..c0e9d9efa3 --- /dev/null +++ b/frontend/editor/src/core/utils/toolIOCompat.test.ts @@ -0,0 +1,74 @@ +/** The shared cases in `testing/tool-io-cases.json`, which all three implementations run. */ + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + validateToolChain, + type ToolChainStep, + type ToolDiagnostic, +} from "@app/utils/toolIOCompat"; +import { type ToolEndpoint, TOOL_ENDPOINTS } from "@app/types/toolApiTypes"; +import { + type ToolFormat, + type ToolIOSpec, + type ToolIOTable, +} from "@app/types/toolIO"; + +interface SharedCase { + name: string; + sourceFormat?: ToolFormat; + steps: { spec: string | null; parameters?: Record }[]; + expected: { stepIndex: number; severity: string; code: string }[]; +} + +/** Shared with the backend and engine, so it lives at the repo root. */ +function casesFile(): string { + let current = dirname(new URL(import.meta.url).pathname); + for (let i = 0; i < 12; i++) { + const candidate = resolve(current, "testing/tool-io-cases.json"); + try { + readFileSync(candidate); + return candidate; + } catch { + current = resolve(current, ".."); + } + } + throw new Error( + "testing/tool-io-cases.json not found above the test directory", + ); +} + +const data = JSON.parse(readFileSync(casesFile(), "utf-8")) as { + specs: Record; + cases: SharedCase[]; +}; + +/** The detail payload is free, so compare only the contractual parts. */ +function summarise(diagnostics: ToolDiagnostic[]): string[] { + return diagnostics.map((d) => `${d.stepIndex}:${d.severity}:${d.code}`); +} + +describe("tool chain conformance", () => { + for (const testCase of data.cases) { + it(testCase.name, () => { + const table: ToolIOTable = {}; + const steps: ToolChainStep[] = testCase.steps.map((step, index) => { + // Distinct endpoints so the same spec can appear twice in a chain. Which ones is + // irrelevant: the case supplies its own table, these are just keys. + const operation: ToolEndpoint = TOOL_ENDPOINTS[index]; + if (step.spec !== null) table[operation] = data.specs[step.spec]; + return { operation, parameters: step.parameters }; + }); + + const actual = validateToolChain(steps, { + sourceFormat: testCase.sourceFormat, + toolIO: table, + }); + + expect(summarise(actual)).toEqual( + testCase.expected.map((e) => `${e.stepIndex}:${e.severity}:${e.code}`), + ); + }); + } +}); diff --git a/frontend/editor/src/core/utils/toolIOCompat.ts b/frontend/editor/src/core/utils/toolIOCompat.ts new file mode 100644 index 0000000000..a5f9ea0fe9 --- /dev/null +++ b/frontend/editor/src/core/utils/toolIOCompat.ts @@ -0,0 +1,265 @@ +/** + * Whether a chain of steps can run, from the generated I/O table rather than by running it. + * + * Duplicated in the backend (`ToolChainValidator`) and the AI engine (`tool_io_compat.py`) on + * purpose: this runs on every keystroke, and the desktop build has no cloud backend to ask. + * `testing/tool-io-cases.json` pins all three to the same answers. + */ + +import { + TOOL_IO, + toolIOFor, + type ToolArity, + type ToolFormat, + type ToolIOSpec, + type ToolIOTable, +} from "@app/types/toolIO"; + +export type ToolDiagnosticSeverity = "ERROR" | "WARN" | "INFO"; + +/** Stable identifiers so the UI can pick its own wording. */ +export const ToolDiagnosticCode = { + /** The step declares no I/O, so nothing past it can be checked. */ + Undeclared: "undeclared-operation", + /** The previous step's output is not a format this step accepts. */ + FormatMismatch: "format-mismatch", + /** The previous step's output depends on a parameter that is not set yet. */ + OutputUncertain: "output-uncertain", + /** The pipeline's input files are not a format the first step accepts. */ + SourceMismatch: "source-mismatch", + /** The previous step emits several files and this one runs once per file. */ + FanOut: "fan-out", + /** The previous step emits several files and this one consumes them together. */ + FanIn: "fan-in", +} as const; + +export type ToolDiagnosticCode = + (typeof ToolDiagnosticCode)[keyof typeof ToolDiagnosticCode]; + +export interface ToolDiagnostic { + stepIndex: number; + severity: ToolDiagnosticSeverity; + code: ToolDiagnosticCode; + /** Interpolation values; the wording lives in the locale files. */ + detail: { + operation: string; + accepts?: ToolFormat[]; + produced?: ToolFormat; + }; +} + +export interface ToolChainStep { + /** A plain string: a stored pipeline may name an endpoint this build does not model. */ + operation: string; + /** Only used to resolve an output that depends on one. */ + parameters?: Record; +} + +export interface ToolChainOptions { + /** The format entering step one, when known. */ + sourceFormat?: ToolFormat; + toolIO?: ToolIOTable; +} + +interface ResolvedOutput { + format: ToolFormat; + arity: ToolArity; + /** False when a conditional output turns on a parameter we cannot see. */ + certain: boolean; +} + +function isMultiInput(arity: ToolArity): boolean { + return arity === "MISO" || arity === "MIMO"; +} + +function isMultiOutput(arity: ToolArity): boolean { + return arity === "SIMO" || arity === "MIMO"; +} + +function normalise(value: unknown): string { + return value === null || value === undefined + ? "" + : String(value).trim().toLowerCase(); +} + +function acceptsFormat(spec: ToolIOSpec, format: ToolFormat): boolean { + return ( + format === "ANY" || + spec.accepts.includes("ANY") || + spec.accepts.includes(format) + ); +} + +/** + * First case whose conditions all hold wins. If none match but one reads a parameter we cannot + * see, the declared output comes back uncertain: an unseen value might have picked another branch. + */ +export function resolveOutput( + spec: ToolIOSpec, + parameters?: Record, +): ResolvedOutput { + let sawUnknownParam = false; + for (const rule of spec.cases ?? []) { + let allHold = true; + for (const condition of rule.when) { + if (!parameters || !(condition.param in parameters)) { + sawUnknownParam = true; + allHold = false; + continue; + } + const value = normalise(parameters[condition.param]); + allHold &&= condition.matches.some((match) => normalise(match) === value); + } + if (allHold) { + return { format: rule.produces, arity: rule.arity, certain: true }; + } + } + return { + format: spec.produces, + arity: spec.arity, + certain: !sawUnknownParam, + }; +} + +export function validateToolChain( + steps: ToolChainStep[], + options: ToolChainOptions = {}, +): ToolDiagnostic[] { + const table = options.toolIO ?? TOOL_IO; + const diagnostics: ToolDiagnostic[] = []; + let carried: ResolvedOutput | null = null; + + steps.forEach((step, index) => { + const spec = toolIOFor(step.operation, table); + if (!spec) { + diagnostics.push({ + stepIndex: index, + severity: "WARN", + code: ToolDiagnosticCode.Undeclared, + detail: { operation: step.operation }, + }); + // Nothing is known past an undeclared step. + carried = null; + return; + } + + // Only the first step is handed the pipeline's input. Every later step is handed the previous + // step's output, which is simply unknown once an undeclared step intervened - checking it + // against the input again would judge it on a format it never receives. + if (index === 0) { + if (options.sourceFormat && !acceptsFormat(spec, options.sourceFormat)) { + diagnostics.push({ + stepIndex: index, + severity: "ERROR", + code: ToolDiagnosticCode.SourceMismatch, + detail: { + operation: step.operation, + accepts: spec.accepts, + produced: options.sourceFormat, + }, + }); + } + } else if (carried !== null) { + diagnostics.push(...checkTransition(index, step, spec, carried)); + } + carried = resolveOutput(spec, step.parameters); + }); + + return diagnostics; +} + +function checkTransition( + index: number, + step: ToolChainStep, + spec: ToolIOSpec, + previous: ResolvedOutput, +): ToolDiagnostic[] { + const detail = { + operation: step.operation, + accepts: spec.accepts, + produced: previous.format, + }; + + if (previous.format === "NONE") { + return [ + { + stepIndex: index, + severity: "ERROR", + code: ToolDiagnosticCode.FormatMismatch, + detail, + }, + ]; + } + + if (!acceptsFormat(spec, previous.format)) { + return [ + { + stepIndex: index, + // Unresolved output: may yet be fine once the step is configured. + severity: previous.certain ? "ERROR" : "WARN", + code: previous.certain + ? ToolDiagnosticCode.FormatMismatch + : ToolDiagnosticCode.OutputUncertain, + detail, + }, + ]; + } + + if (!previous.certain) { + return [ + { + stepIndex: index, + severity: "WARN", + code: ToolDiagnosticCode.OutputUncertain, + detail, + }, + ]; + } + + if (isMultiOutput(previous.arity)) { + return [ + { + stepIndex: index, + severity: "INFO", + code: isMultiInput(spec.arity) + ? ToolDiagnosticCode.FanIn + : ToolDiagnosticCode.FanOut, + detail, + }, + ]; + } + + return []; +} + +/** What a newly appended step would be handed, or undefined when the last step declares nothing. */ +export function chainOutputFormat( + steps: ToolChainStep[], + toolIO: ToolIOTable = TOOL_IO, +): ToolFormat | undefined { + const last = steps.at(-1); + if (!last) return undefined; + const spec = toolIOFor(last.operation, toolIO); + return spec ? resolveOutput(spec, last.parameters).format : undefined; +} + +/** Unknown operations are not claimed to be a problem. */ +export function toolAcceptsFormat( + operation: string, + format: ToolFormat, + toolIO: ToolIOTable = TOOL_IO, +): boolean { + const spec = toolIOFor(operation, toolIO); + return spec ? acceptsFormat(spec, format) : true; +} + +export function hasBlockingDiagnostics(diagnostics: ToolDiagnostic[]): boolean { + return diagnostics.some((d) => d.severity === "ERROR"); +} + +export function diagnosticsForStep( + diagnostics: ToolDiagnostic[], + stepIndex: number, +): ToolDiagnostic[] { + return diagnostics.filter((d) => d.stepIndex === stepIndex); +} diff --git a/frontend/editor/src/core/utils/toolIOLabels.test.ts b/frontend/editor/src/core/utils/toolIOLabels.test.ts new file mode 100644 index 0000000000..ac6b4c155b --- /dev/null +++ b/frontend/editor/src/core/utils/toolIOLabels.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { TOOL_FORMATS, type ToolFormat } from "@app/types/toolIO"; +import { + getToolFormatLabel, + getToolFormatListLabel, +} from "@app/utils/toolIOLabels"; + +/** The en-US `[toolFormat]` block, read straight from the locale file. */ +function toolFormatLabels(): Record { + let current = dirname(new URL(import.meta.url).pathname); + for (let i = 0; i < 12; i++) { + try { + const toml = readFileSync( + resolve(current, "public/locales/en-US/translation.toml"), + "utf-8", + ); + const section = toml.split("\n[toolFormat]\n")[1]?.split("\n[")[0] ?? ""; + return Object.fromEntries( + section + .split("\n") + .map((line) => /^(\w+) = "(.*)"$/.exec(line.trim())) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => [m[1], m[2]]), + ); + } catch { + current = resolve(current, ".."); + } + } + throw new Error("en-US translation.toml not found above the test directory"); +} + +const labels = toolFormatLabels(); +const t = ((key: string) => + labels[key.replace("toolFormat.", "")] ?? key) as never; + +describe("tool format labels", () => { + it("gives every format a phrase, so no wire value reaches the reader", () => { + const missing = TOOL_FORMATS.filter((format) => !labels[format]); + expect(missing).toEqual([]); + }); + + it("never renders the enum name itself", () => { + // PDF_ENCRYPTED is the giveaway: a label that still shouts in SCREAMING_SNAKE + // is a wire value that escaped into the UI. + for (const format of TOOL_FORMATS) { + const label = getToolFormatLabel(t, format); + expect(label).not.toMatch(/^[A-Z][A-Z_]*$/); + } + }); + + it("names an encrypted PDF in words", () => { + expect(getToolFormatLabel(t, "PDF_ENCRYPTED")).toBe( + "a password-protected PDF", + ); + }); + + it("joins alternatives the way the language does", () => { + const formats: ToolFormat[] = ["PDF", "IMAGE"]; + expect(getToolFormatListLabel(t, "en-US", formats)).toBe("a PDF or images"); + }); + + it("renders a single format without a conjunction", () => { + expect(getToolFormatListLabel(t, "en-US", ["PDF"])).toBe("a PDF"); + }); +}); diff --git a/frontend/editor/src/core/utils/toolIOLabels.ts b/frontend/editor/src/core/utils/toolIOLabels.ts new file mode 100644 index 0000000000..6a96f5f2f7 --- /dev/null +++ b/frontend/editor/src/core/utils/toolIOLabels.ts @@ -0,0 +1,25 @@ +import { type TFunction } from "i18next"; +import { type ToolFormat } from "@app/types/toolIO"; + +/** + * A tool format as a phrase to show someone, not the enum name. `PDF_ENCRYPTED` is a wire value; + * the reader sees "a password-protected PDF". + */ +export const getToolFormatLabel = (t: TFunction, format: ToolFormat): string => + t(`toolFormat.${format}`); + +/** + * Several formats as one phrase, joined the way the reader's language joins alternatives: + * "a PDF or an image". + */ +export function getToolFormatListLabel( + t: TFunction, + language: string, + formats: ToolFormat[], +): string { + const labels = formats.map((format) => getToolFormatLabel(t, format)); + return new Intl.ListFormat(language, { + style: "long", + type: "disjunction", + }).format(labels); +} diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx index 99395a0ffe..64f8ec7fa0 100644 --- a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx @@ -7,6 +7,9 @@ import { type SubcategoryId, } from "@app/data/toolsTaxonomy"; import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; +import { toolAcceptsFormat } from "@app/utils/toolIOCompat"; +import { getToolFormatLabel } from "@app/utils/toolIOLabels"; +import { type ToolFormat } from "@app/types/toolIO"; import { searchOperations, type StepOperation, @@ -24,6 +27,12 @@ interface ToolPickerProps { */ operations?: StepOperation[]; onPickOperation?: (operation: StepOperation) => void; + /** + * What the step before this one produces, when known. Tools that cannot run on it are marked + * rather than hidden: the chain is still editable in any order, and the builder explains the + * problem once the step is added. + */ + precedingOutput?: ToolFormat; } /** @@ -36,6 +45,7 @@ export function ToolPicker({ onClose, operations = [], onPickOperation, + precedingOutput, }: ToolPickerProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); @@ -114,6 +124,14 @@ export function ToolPicker({ {tool.name} + {precedingOutput && + !toolAcceptsFormat(tool.endpoint, precedingOutput) ? ( + + {t("portal.pipelines.builder.cannotFollow", { + produced: getToolFormatLabel(t, precedingOutput), + })} + + ) : null} ))} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 50a86f7313..128e8d4a1b 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -164,6 +164,19 @@ color: var(--c-text-subtle); } +/* A step that cannot run on what the one before it produces. */ +.portal-builder__step-note--danger { + color: var(--c-danger); +} + +/* A picker entry that cannot run on what the chain currently produces. */ +.portal-pipelines__picker-note { + margin-left: auto; + padding-left: 0.5rem; + font-size: 0.6875rem; + color: var(--c-text-subtle); +} + .portal-builder__step-actions { display: flex; gap: 0.25rem; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index 7587953e12..e06025889e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -111,7 +111,28 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; - const allTools = { compress } as unknown as ToolRegistryCatalog["allTools"]; + // Produces images, so nothing downstream that wants a PDF can follow it. + const extractImages = { + name: "Extract images", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + operationConfig: { + operationType: "extractImages", + toolType: 0, + endpoint: "/api/v1/misc/extract-images", + defaultParameters: {}, + buildFormData: () => new FormData(), + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; + const allTools = { + compress, + extractImages, + } as unknown as ToolRegistryCatalog["allTools"]; const catalog: ToolRegistryCatalog = { regularTools: allTools, superTools: allTools, @@ -260,6 +281,55 @@ describe("PipelineBuilder", () => { expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); + it("blocks saving a chain whose steps can't run on each other", async () => { + renderBuilder("/processor/pipelines/new"); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "portal.pipelines.composer.name", + }), + { target: { value: "Broken chain" } }, + ); + await pickInputSource("Claims intake"); + fireEvent.click(screen.getByText("pick output")); + + // Extract images emits images; compress only takes a PDF, so it can never run. + fireEvent.click(screen.getByRole("button", { name: /addTool/ })); + fireEvent.click(await screen.findByText("Extract images")); + fireEvent.click(screen.getByRole("button", { name: /addTool/ })); + fireEvent.click(await screen.findByText("Compress")); + + expect( + await screen.findByText("portal.pipelines.builder.stepsIncompatible"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("allows a chain whose steps line up", async () => { + renderBuilder("/processor/pipelines/new"); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "portal.pipelines.composer.name", + }), + { target: { value: "Fine chain" } }, + ); + await pickInputSource("Claims intake"); + fireEvent.click(screen.getByText("pick output")); + + fireEvent.click(screen.getByRole("button", { name: /addTool/ })); + fireEvent.click(await screen.findByText("Compress")); + + expect( + screen.queryByText("portal.pipelines.builder.stepsIncompatible"), + ).not.toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).not.toBeDisabled(); + }); + it("requires at least one source and one destination before saving", async () => { renderBuilder("/processor/pipelines/new"); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 4227dbcd11..d788fc4f35 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -31,6 +31,17 @@ import { type ExecutableTool, type WorkingToolStep, } from "@app/hooks/tools/shared/toolAutomation"; +import { + getToolFormatLabel, + getToolFormatListLabel, +} from "@app/utils/toolIOLabels"; +import { + chainOutputFormat, + diagnosticsForStep, + hasBlockingDiagnostics, + validateToolChain, + type ToolDiagnostic, +} from "@app/utils/toolIOCompat"; import { errorMessage } from "@portal/api/http"; import { deletePipeline, @@ -154,7 +165,7 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null { * deletes it. Replaces the former modal composer and the list's inline detail card. */ export function PipelineBuilder() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); // Pipelines are stored as policies, so a save/delete must invalidate both the @@ -402,6 +413,66 @@ export function PipelineBuilder() { .map(stepLabel); const hasUnconfiguredSteps = unconfiguredStepLabels.length > 0; + // Whether each step can actually accept what the one before it produces. Checked here from the + // generated tool I/O table rather than by running the pipeline, so an impossible chain (say, + // Extract Images then Rotate) is caught while it is being built. + const chainDiagnostics = useMemo( + () => + validateToolChain( + steps.map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps], + ); + const blockingSteps = chainDiagnostics + .filter((d) => d.severity === "ERROR") + .map((d) => stepLabel(steps[d.stepIndex])); + const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics); + + // What a newly added step would be handed, so the picker can flag tools that cannot take it. + const chainOutput = useMemo( + () => + chainOutputFormat( + steps.map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps], + ); + + function diagnosticNote(diagnostic: ToolDiagnostic): string { + const { accepts, produced } = diagnostic.detail; + return t(`portal.pipelines.builder.diagnostic.${diagnostic.code}`, { + accepts: getToolFormatListLabel(t, i18n.language, accepts ?? []), + produced: produced ? getToolFormatLabel(t, produced) : "", + }); + } + + /** The most severe diagnostic for a step, rendered as its note. */ + function renderStepDiagnostic(index: number) { + const forStep = diagnosticsForStep(chainDiagnostics, index); + const diagnostic = + forStep.find((d) => d.severity === "ERROR") ?? + forStep.find((d) => d.severity === "WARN") ?? + forStep[0]; + if (!diagnostic) return null; + return ( + + {diagnosticNote(diagnostic)} + + ); + } + // Track unsaved edits: snapshot the form and compare against the state captured just after // seeding, so leaving the builder can prompt to save or discard. const snapshot = JSON.stringify({ @@ -430,6 +501,7 @@ export function PipelineBuilder() { outputValid && !hasUploadSteps && !hasUnconfiguredSteps && + !hasIncompatibleSteps && !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); @@ -707,6 +779,14 @@ export function PipelineBuilder() { })} /> )} + {hasIncompatibleSteps && ( + + )} {/* Pipeline-level settings, above the operation list. */}

@@ -885,7 +965,9 @@ export function PipelineBuilder() { {t("portal.pipelines.builder.unknownStep")} - ) : null} + ) : ( + renderStepDiagnostic(i) + )}
@@ -930,6 +1012,7 @@ export function PipelineBuilder() { onPick={addStep} operations={STEP_OPERATIONS} onPickOperation={addOperationStep} + precedingOutput={chainOutput} onClose={() => setPickerOpen(false)} /> ) : ( diff --git a/testing/tool-io-cases.json b/testing/tool-io-cases.json new file mode 100644 index 0000000000..59c2802f8d --- /dev/null +++ b/testing/tool-io-cases.json @@ -0,0 +1,279 @@ +{ + "$comment": [ + "Shared conformance cases for the tool I/O compatibility check.", + "Run by Java (ToolChainValidatorConformanceTest), the frontend (toolIOCompat.test.ts) and the", + "engine (test_tool_io_compat.py) against their own implementations, which must agree.", + "Specs are synthetic, so retyping a real endpoint never invalidates a case.", + "'expected' is matched on stepIndex/severity/code; messages are free text." + ], + "specs": { + "pdfToPdf": { "accepts": ["PDF"], "produces": "PDF", "arity": "SISO", "cases": [] }, + "pdfToPdfSplit": { "accepts": ["PDF"], "produces": "PDF", "arity": "SIMO", "cases": [] }, + "pdfMerge": { "accepts": ["PDF"], "produces": "PDF", "arity": "MISO", "cases": [] }, + "addPassword": { "accepts": ["PDF"], "produces": "PDF_ENCRYPTED", "arity": "SISO", "cases": [] }, + "removePassword": { "accepts": ["PDF_ENCRYPTED"], "produces": "PDF", "arity": "SISO", "cases": [] }, + "extractImages": { "accepts": ["PDF"], "produces": "IMAGE", "arity": "SIMO", "cases": [] }, + "imageToPdf": { "accepts": ["IMAGE"], "produces": "PDF", "arity": "MISO", "cases": [] }, + "getAttachments": { "accepts": ["PDF"], "produces": "ZIP", "arity": "SISO", "cases": [] }, + "getInfo": { "accepts": ["PDF"], "produces": "JSON", "arity": "SISO", "cases": [] }, + "validateOnly": { "accepts": ["PDF"], "produces": "NONE", "arity": "SISO", "cases": [] }, + "anyToPdf": { "accepts": ["ANY"], "produces": "PDF", "arity": "SISO", "cases": [] }, + "pdfToAny": { "accepts": ["PDF"], "produces": "ANY", "arity": "SISO", "cases": [] }, + "pdfOrImage": { "accepts": ["PDF", "IMAGE"], "produces": "PDF", "arity": "SISO", "cases": [] }, + "pdfToImageConfigurable": { + "accepts": ["PDF"], + "produces": "IMAGE", + "arity": "SIMO", + "cases": [ + { + "when": [{ "param": "singleOrMultiple", "matches": ["single"] }], + "produces": "IMAGE", + "arity": "SISO" + } + ] + }, + "addPasswordConfigurable": { + "accepts": ["PDF"], + "produces": "PDF_ENCRYPTED", + "arity": "SISO", + "cases": [ + { + "when": [{ "param": "password", "matches": [""] }], + "produces": "PDF", + "arity": "SISO" + } + ] + }, + "pdfToImageMixedCase": { + "accepts": ["PDF"], + "produces": "IMAGE", + "arity": "SIMO", + "cases": [ + { + "when": [{ "param": "singleOrMultiple", "matches": [" SiNgLe "] }], + "produces": "IMAGE", + "arity": "SISO" + } + ] + }, + "addPasswordTwoKeys": { + "accepts": ["PDF"], + "produces": "PDF_ENCRYPTED", + "arity": "SISO", + "cases": [ + { + "when": [ + { "param": "password", "matches": [""] }, + { "param": "ownerPassword", "matches": [""] } + ], + "produces": "PDF", + "arity": "SISO" + } + ] + } + }, + "cases": [ + { + "name": "a plain pdf chain is clean", + "steps": [{ "spec": "pdfToPdf" }, { "spec": "pdfToPdf" }], + "expected": [] + }, + { + "name": "an empty chain is clean", + "steps": [], + "expected": [] + }, + { + "name": "a single step is never a transition", + "steps": [{ "spec": "extractImages" }], + "expected": [] + }, + { + "name": "encrypting then editing is rejected", + "steps": [{ "spec": "addPassword" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "encrypting then decrypting is allowed", + "steps": [{ "spec": "addPassword" }, { "spec": "removePassword" }], + "expected": [] + }, + { + "name": "decrypting then editing is allowed", + "steps": [{ "spec": "removePassword" }, { "spec": "pdfToPdf" }], + "expected": [] + }, + { + "name": "an ordinary tool cannot be handed an encrypted pdf from source", + "sourceFormat": "PDF_ENCRYPTED", + "steps": [{ "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 0, "severity": "ERROR", "code": "source-mismatch" }] + }, + { + "name": "remove-password is the one thing an encrypted source can start with", + "sourceFormat": "PDF_ENCRYPTED", + "steps": [{ "spec": "removePassword" }, { "spec": "pdfToPdf" }], + "expected": [] + }, + { + "name": "extracted images cannot feed a pdf tool", + "steps": [{ "spec": "extractImages" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "extracted images can feed an image tool, and fan in", + "steps": [{ "spec": "extractImages" }, { "spec": "imageToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-in" }] + }, + { + "name": "splitting then a single-file tool fans out", + "steps": [{ "spec": "pdfToPdfSplit" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-out" }] + }, + { + "name": "splitting then merging fans in", + "steps": [{ "spec": "pdfToPdfSplit" }, { "spec": "pdfMerge" }], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-in" }] + }, + { + "name": "an archive deliverable does not feed a pdf tool", + "steps": [{ "spec": "getAttachments" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "a json report does not feed a pdf tool", + "steps": [{ "spec": "getInfo" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "a step producing no file terminates the chain", + "steps": [{ "spec": "validateOnly" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "a tool accepting anything swallows any upstream output", + "steps": [{ "spec": "extractImages" }, { "spec": "anyToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-out" }] + }, + { + "name": "a tool producing anything satisfies any downstream input", + "steps": [{ "spec": "pdfToAny" }, { "spec": "imageToPdf" }], + "expected": [] + }, + { + "name": "a source of unknown type is not checked against step one", + "steps": [{ "spec": "imageToPdf" }], + "expected": [] + }, + { + "name": "a multi-format tool accepts either branch", + "steps": [{ "spec": "extractImages" }, { "spec": "pdfOrImage" }], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-out" }] + }, + { + "name": "an unconfigured parameter-dependent output only warns", + "steps": [{ "spec": "addPasswordConfigurable" }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "WARN", "code": "output-uncertain" }] + }, + { + "name": "configuring the parameter to the encrypting branch makes it an error", + "steps": [ + { "spec": "addPasswordConfigurable", "parameters": { "password": "hunter2" } }, + { "spec": "pdfToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "configuring the parameter to the non-encrypting branch clears it", + "steps": [ + { "spec": "addPasswordConfigurable", "parameters": { "password": "" } }, + { "spec": "pdfToPdf" } + ], + "expected": [] + }, + { + "name": "a multi-condition case needs every condition to hold", + "steps": [ + { + "spec": "addPasswordTwoKeys", + "parameters": { "password": "", "ownerPassword": "hunter2" } + }, + { "spec": "pdfToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "a multi-condition case applies when every condition holds", + "steps": [ + { "spec": "addPasswordTwoKeys", "parameters": { "password": "", "ownerPassword": "" } }, + { "spec": "pdfToPdf" } + ], + "expected": [] + }, + { + "name": "a multi-condition case is unresolved when one condition is unconfigured", + "steps": [ + { "spec": "addPasswordTwoKeys", "parameters": { "password": "" } }, + { "spec": "pdfToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "WARN", "code": "output-uncertain" }] + }, + { + "name": "the single branch of an arity case produces one file, so no fan-out", + "steps": [ + { "spec": "pdfToImageConfigurable", "parameters": { "singleOrMultiple": "single" } }, + { "spec": "imageToPdf" } + ], + "expected": [] + }, + { + "name": "the default branch of an arity case fans in", + "steps": [ + { "spec": "pdfToImageConfigurable", "parameters": { "singleOrMultiple": "multiple" } }, + { "spec": "imageToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-in" }] + }, + { + "name": "a condition matches regardless of the casing and padding it was declared with", + "steps": [ + { "spec": "pdfToImageMixedCase", "parameters": { "singleOrMultiple": "SINGLE" } }, + { "spec": "imageToPdf" } + ], + "expected": [] + }, + { + "name": "a condition declared in mixed case still fails on a genuinely different value", + "steps": [ + { "spec": "pdfToImageMixedCase", "parameters": { "singleOrMultiple": "multiple" } }, + { "spec": "imageToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-in" }] + }, + { + "name": "an undeclared step warns and stops the chain being checked past it", + "steps": [{ "spec": "addPassword" }, { "spec": null }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "WARN", "code": "undeclared-operation" }] + }, + { + "name": "the source format is not re-applied to a step that follows an undeclared one", + "sourceFormat": "PDF_ENCRYPTED", + "steps": [{ "spec": "removePassword" }, { "spec": null }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 1, "severity": "WARN", "code": "undeclared-operation" }] + }, + { + "name": "an undeclared first step does not pass the source format on to the next step", + "sourceFormat": "PDF_ENCRYPTED", + "steps": [{ "spec": null }, { "spec": "pdfToPdf" }], + "expected": [{ "stepIndex": 0, "severity": "WARN", "code": "undeclared-operation" }] + }, + { + "name": "every broken transition is reported, not just the first", + "steps": [{ "spec": "extractImages" }, { "spec": "pdfToPdf" }, { "spec": "imageToPdf" }], + "expected": [ + { "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }, + { "stepIndex": 2, "severity": "ERROR", "code": "format-mismatch" } + ] + } + ] +}