From cd199c86591e9a9db31da91d783e6e9442427282 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 3 Aug 2026 15:13:43 +0100 Subject: [PATCH] Define tool inputs & outputs in a structured way (#7204) # Description of Changes Change tool APIs to use structured definitions for input/output/type info because we need that info to be able to validate whether policies can actually successfully work based on whether one tool accepts the output of another. There were various bugs in the previous string definitions because of either misspellings or just incorrect definitions, so I've gone through and fixed all that I can find. image image image image --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .github/config/.files.yaml | 2 + .github/workflows/check-generated-models.yml | 35 +- .taskfiles/engine.yml | 9 +- .taskfiles/frontend.yml | 5 +- DeveloperGuide.md | 35 +- Taskfile.yml | 6 + .../swagger/ToolIOOperationCustomizer.java | 105 +++ .../software/common/model/tool/ToolArity.java | 24 + .../common/model/tool/ToolDiagnostic.java | 47 ++ .../common/model/tool/ToolFormat.java | 62 ++ .../software/common/model/tool/ToolIO.java | 30 + .../common/model/tool/ToolIOCase.java | 22 + .../common/model/tool/ToolIOSource.java | 19 + .../common/model/tool/ToolIOSpec.java | 108 +++ .../common/model/tool/ToolIOWhen.java | 18 + .../common/service/ToolChainValidator.java | 168 +++++ .../common/service/ToolIORegistry.java | 127 ++++ .../common/service/ToolMetadataService.java | 10 +- .../common/util/RegexPatternUtils.java | 21 - .../ToolChainValidatorConformanceTest.java | 159 +++++ .../common/service/ToolIODiscoveryTest.java | 119 ++++ .../common/service/ToolIORegistryTest.java | 87 +++ .../util/RegexPatternUtilsMoreTest.java | 5 +- .../controller/api/AnalysisController.java | 19 +- .../api/BookletImpositionController.java | 9 +- .../SPDF/controller/api/CropController.java | 5 +- .../api/EditTableOfContentsController.java | 4 + .../controller/api/EditTextController.java | 19 +- .../SPDF/controller/api/MergeController.java | 6 +- .../api/MultiPageLayoutController.java | 5 +- .../controller/api/PdfOverlayController.java | 6 +- .../controller/api/PosterPdfController.java | 11 +- .../api/RearrangePagesPDFController.java | 12 +- .../controller/api/RotationController.java | 5 +- .../controller/api/ScalePagesController.java | 5 +- .../controller/api/SplitPDFController.java | 9 +- .../api/SplitPdfByChaptersController.java | 8 +- .../api/SplitPdfBySectionsController.java | 9 +- .../api/SplitPdfBySizeController.java | 11 +- .../api/ToSinglePageController.java | 8 +- .../ConvertEbookToPDFController.java | 5 +- .../api/converters/ConvertEmlToPDF.java | 11 +- .../api/converters/ConvertHtmlToPDF.java | 9 +- .../converters/ConvertImgPDFController.java | 38 +- .../api/converters/ConvertMarkdownToPdf.java | 10 +- .../converters/ConvertOfficeController.java | 7 +- .../ConvertPDFToEpubController.java | 7 +- .../ConvertPDFToExcelController.java | 5 +- .../api/converters/ConvertPDFToHtml.java | 6 +- .../api/converters/ConvertPDFToOffice.java | 31 +- .../api/converters/ConvertPDFToPDFA.java | 7 +- .../converters/ConvertPdfJsonController.java | 10 +- .../ConvertPdfToVideoController.java | 3 +- .../api/converters/ConvertSvgToPDF.java | 25 +- .../api/converters/ConvertWebsiteToPDF.java | 6 +- .../api/converters/ExtractCSVController.java | 7 +- .../converters/PdfVectorExportController.java | 30 +- .../api/filters/FilterController.java | 32 +- .../api/misc/AddCommentsController.java | 12 +- .../api/misc/AttachmentController.java | 22 +- .../api/misc/AutoRenameController.java | 5 +- .../api/misc/AutoRotateController.java | 15 +- .../api/misc/AutoSplitPdfController.java | 9 +- .../api/misc/BlankPageController.java | 7 +- .../api/misc/CompressController.java | 5 +- .../api/misc/DecompressPdfController.java | 3 + .../api/misc/ExtractImageScansController.java | 7 +- .../api/misc/ExtractImagesController.java | 7 +- .../api/misc/FlattenController.java | 5 +- .../api/misc/MetadataController.java | 6 +- .../controller/api/misc/OCRController.java | 20 +- .../api/misc/OverlayImageController.java | 12 +- .../api/misc/PageNumbersController.java | 7 +- .../api/misc/PrintFileController.java | 4 +- .../api/misc/RemoveImagesController.java | 5 +- .../controller/api/misc/RepairController.java | 9 +- .../misc/ReplaceAndInvertColorController.java | 8 +- .../api/misc/ScannerEffectController.java | 6 +- .../controller/api/misc/ShowJavascript.java | 5 +- .../controller/api/misc/StampController.java | 6 +- .../api/misc/UnlockPDFFormsController.java | 7 +- .../api/pipeline/PipelineController.java | 3 +- .../pipeline/PipelineDirectoryProcessor.java | 6 +- .../api/pipeline/PipelineProcessor.java | 9 +- .../api/security/CertSignController.java | 5 +- .../controller/api/security/GetInfoOnPDF.java | 6 +- .../api/security/PasswordController.java | 23 +- .../api/security/RedactController.java | 22 +- .../security/RemoveCertSignController.java | 5 +- .../api/security/SanitizeController.java | 5 +- .../api/security/TimestampController.java | 8 +- .../security/ValidateSignatureController.java | 8 +- .../api/security/VerifyPDFController.java | 10 +- .../api/security/WatermarkController.java | 7 +- .../api/converters/ConvertPDFToMarkdown.java | 6 +- .../software/SPDF/service/ApiDocService.java | 120 +--- .../config/ToolIODeclarationCoverageTest.java | 342 ++++++++++ .../api/pipeline/PipelineProcessorTest.java | 16 +- .../SPDF/service/ApiDocServiceTest.java | 376 +---------- .../api/MathAuditorAgentController.java | 2 - .../api/PdfCommentAgentController.java | 2 - .../api/ExternalApiCallController.java | 7 +- .../purview/PurviewLabelController.java | 10 +- .../policy/controller/PolicyController.java | 20 + .../policy/engine/PolicyValidator.java | 44 ++ .../policy/model/PipelineValidation.java | 39 ++ .../controller/api/EmailController.java | 4 +- .../controller/SigningSessionController.java | 2 +- .../controller/PolicyControllerTest.java | 59 ++ .../controller/PolicyRunRoutesTest.java | 4 +- .../policy/engine/PolicyValidatorTest.java | 79 ++- engine/scripts/generate_tool_models.py | 178 ++++- engine/src/stirling/agents/pdf_edit.py | 86 ++- engine/src/stirling/models/tool_io.py | 261 ++++++++ engine/src/stirling/services/__init__.py | 5 + .../src/stirling/services/tool_io_compat.py | 216 ++++++ engine/tests/test_pdf_edit_agent.py | 124 ++++ engine/tests/test_tool_io_compat.py | 59 ++ .../public/locales/en-US/translation.toml | 36 + .../scripts/generate-tool-api-types.mts | 228 ++++++- .../editor/src/core/types/toolApiTypes.ts | 113 ++++ frontend/editor/src/core/types/toolIO.ts | 616 ++++++++++++++++++ .../src/core/utils/toolIOCompat.test.ts | 74 +++ .../editor/src/core/utils/toolIOCompat.ts | 265 ++++++++ .../src/core/utils/toolIOLabels.test.ts | 67 ++ .../editor/src/core/utils/toolIOLabels.ts | 25 + .../components/pipelines/ToolPicker.tsx | 18 + .../src/portal/views/PipelineBuilder.css | 13 + .../src/portal/views/PipelineBuilder.test.tsx | 72 +- .../src/portal/views/PipelineBuilder.tsx | 87 ++- testing/tool-io-cases.json | 279 ++++++++ 131 files changed, 5026 insertions(+), 860 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolArity.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolDiagnostic.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolFormat.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolIO.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolIOCase.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolIOSource.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java create mode 100644 app/common/src/main/java/stirling/software/common/model/tool/ToolIOWhen.java create mode 100644 app/common/src/main/java/stirling/software/common/service/ToolChainValidator.java create mode 100644 app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java create mode 100644 app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/ToolIODiscoveryTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/ToolIORegistryTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineValidation.java create mode 100644 engine/src/stirling/models/tool_io.py create mode 100644 engine/src/stirling/services/tool_io_compat.py create mode 100644 engine/tests/test_tool_io_compat.py create mode 100644 frontend/editor/src/core/types/toolIO.ts create mode 100644 frontend/editor/src/core/utils/toolIOCompat.test.ts create mode 100644 frontend/editor/src/core/utils/toolIOCompat.ts create mode 100644 frontend/editor/src/core/utils/toolIOLabels.test.ts create mode 100644 frontend/editor/src/core/utils/toolIOLabels.ts create mode 100644 testing/tool-io-cases.json 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" } + ] + } + ] +}