Endpoint UI data for V2 (#4044)

# Description of Changes
This pull request introduces several enhancements and new features,
primarily focused on improving API documentation support, expanding
backend functionality, and adding new frontend tools. Key changes
include the integration of Swagger API documentation, the addition of a
new `UIDataController` for backend data handling, and updates to the
frontend to include a Swagger UI tool.

### Backend Enhancements:
* **Swagger API Documentation Integration**:
- Added support for dynamically configuring Swagger servers using the
`SWAGGER_SERVER_URL` environment variable in `OpenApiConfig`
(`[app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.javaR54-L63](diffhunk://#diff-6080fb3dc14efc430c9de1bf9fa4996a23deebc14230dde7788949b2c49cca68R54-L63)`).
- Imported necessary Swagger dependencies
(`[app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.javaR13](diffhunk://#diff-6080fb3dc14efc430c9de1bf9fa4996a23deebc14230dde7788949b2c49cca68R13)`).
- Updated `nginx.conf` to proxy Swagger-related requests to the backend
(`[docker/frontend/nginx.confR55-R92](diffhunk://#diff-6d35fafb4405bd052c6d5e48bd946bcef7c77552a74e1b902de45e85eee09aceR55-R92)`).

* **New `UIDataController`**:
- Introduced a new controller (`UIDataController`) to serve React UI
data, including endpoints for home data, licenses, pipeline
configurations, signature data, and OCR data
(`[app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.javaR1-R301](diffhunk://#diff-3e7063d4e921c7b9e6eedfcad0e535ba3eff68476dcff5e6f28b00c388cff646R1-R301)`).

* **Endpoint Handling**:
- Modified `ConfigController` to include explicit parameter naming for
better clarity in API requests
(`[app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.javaL113-R120](diffhunk://#diff-43d19d45ae547fd79090596c06d58cb0eb7f722ed43eb4da59f9dec39f6def6eL113-R120)`).

### Frontend Enhancements:
* **Swagger UI Tool**:
- Added a new tool definition (`swagger`) in `useToolManagement.tsx`,
with an icon and lazy-loaded component
(`[frontend/src/hooks/useToolManagement.tsxR30-R38](diffhunk://#diff-57f8a6b3e75ecaec10ad445b01afe8fccc376af6f8ad4d693c68cf98e8863273R30-R38)`).
- Implemented the `SwaggerUI` component to open the Swagger
documentation in a new tab
(`[frontend/src/tools/SwaggerUI.tsxR1-R18](diffhunk://#diff-ca9bdf83c5d611a5edff10255103d7939895635b33a258dd77db6571da6c4600R1-R18)`).

* **Localization Updates**:
- Updated English (US and GB) translation files to include
Swagger-related strings
(`[[1]](diffhunk://#diff-14c707e28788a3a84ed5293ff6689be73d4bca00e155beaf090f9b37c978babbR578-R581)`,
`[[2]](diffhunk://#diff-14c707e28788a3a84ed5293ff6689be73d4bca00e155beaf090f9b37c978babbR1528-R1533)`,
`[[3]](diffhunk://#diff-e4d543afa388d9eb8a423e45dfebb91641e3558d00848d70b285ebb91c40b249R578-R581)`,
`[[4]](diffhunk://#diff-e4d543afa388d9eb8a423e45dfebb91641e3558d00848d70b285ebb91c40b249R1528-R1533)`).

### Workflow Updates:
* **Environment Variable Additions**:
- Added `SWAGGER_SERVER_URL` to the `PR-Auto-Deploy-V2.yml` and
`deploy-on-v2-commit.yml` workflows for configuring Swagger server URLs
(`[[1]](diffhunk://#diff-931fcb06ba030420d7044dde06465ad55b4e769a9bd374dcd6a0c76f79a5e30eR320)`,
`[[2]](diffhunk://#diff-f8b6ec3c0af9cd2d8dffef6f3def2be6357fe596a606850ca7f5d799e1349069R150)`).
<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-07-29 13:11:53 +01:00
committed by GitHub
parent 3b82602744
commit 1ce813bcd8
13 changed files with 894 additions and 6 deletions

View File

@@ -39,6 +39,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI();
if (requestURI.contains("/api/")) {
return true;
}
Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters

View File

@@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import lombok.RequiredArgsConstructor;
@@ -50,17 +51,26 @@ public class OpenApiConfig {
.url("https://www.stirlingpdf.com")
.email("contact@stirlingpdf.com"))
.description(DEFAULT_DESCRIPTION);
OpenAPI openAPI = new OpenAPI().info(info);
// Add server configuration from environment variable
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
Server server = new Server().url(swaggerServerUrl).description("API Server");
openAPI.addServersItem(server);
}
if (!applicationProperties.getSecurity().getEnableLogin()) {
return new OpenAPI().components(new Components()).info(info);
return openAPI.components(new Components());
} else {
SecurityScheme apiKeyScheme =
new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name("X-API-KEY");
return new OpenAPI()
return openAPI
.components(new Components().addSecuritySchemes("apiKey", apiKeyScheme))
.info(info)
.addSecurityItem(new SecurityRequirement().addList("apiKey"));
}
}

View File

@@ -0,0 +1,301 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@Slf4j
@RestController
@RequestMapping("/api/v1/ui-data")
@Tag(name = "UI Data", description = "APIs for React UI data")
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
this.applicationProperties = applicationProperties;
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
String showSurvey = System.getenv("SHOW_SURVEY");
boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey);
HomeData data = new HomeData();
data.setShowSurveyFromDocker(showSurveyValue);
return ResponseEntity.ok(data);
}
@GetMapping("/licenses")
@Operation(summary = "Get third-party licenses data")
public ResponseEntity<LicensesData> getLicensesData() {
LicensesData data = new LicensesData();
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData =
mapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
data.setDependencies(Collections.emptyList());
}
return ResponseEntity.ok(data);
}
@GetMapping("/pipeline")
@Operation(summary = "Get pipeline configuration data")
public ResponseEntity<PipelineData> getPipelineData() {
PipelineData data = new PipelineData();
List<String> pipelineConfigs = new ArrayList<>();
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
try (Stream<Path> paths =
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
List<Path> jsonFiles =
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.toList();
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent =
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
.getFileName()
.toString();
name = filename.substring(0, filename.lastIndexOf('.'));
}
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
pipelineConfigsWithNames.add(configWithName);
}
} catch (IOException e) {
log.error("Failed to load pipeline configs", e);
}
}
if (pipelineConfigsWithNames.isEmpty()) {
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", "");
configWithName.put("name", "No preloaded configs found");
pipelineConfigsWithNames.add(configWithName);
}
data.setPipelineConfigsWithNames(pipelineConfigsWithNames);
data.setPipelineConfigs(pipelineConfigs);
return ResponseEntity.ok(data);
}
@GetMapping("/sign")
@Operation(summary = "Get signature form data")
public ResponseEntity<SignData> getSignData() {
String username = "";
if (userService != null) {
username = userService.getCurrentUsername();
}
List<SignatureFile> signatures = signatureService.getAvailableSignatures(username);
List<FontResource> fonts = getFontNames();
SignData data = new SignData();
data.setSignatures(signatures);
data.setFonts(fonts);
return ResponseEntity.ok(data);
}
@GetMapping("/ocr-pdf")
@Operation(summary = "Get OCR PDF data")
public ResponseEntity<OcrData> getOcrPdfData() {
List<String> languages = getAvailableTesseractLanguages();
OcrData data = new OcrData();
data.setLanguages(languages);
return ResponseEntity.ok(data);
}
private List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !"osd".equalsIgnoreCase(lang))
.sorted()
.toList();
}
private List<FontResource> getFontNames() {
List<FontResource> fontNames = new ArrayList<>();
fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2"));
fontNames.addAll(
getFontNamesFromLocation(
"file:"
+ InstallationPathConfig.getStaticPath()
+ "fonts"
+ java.io.File.separator
+ "*"));
return fontNames;
}
private List<FontResource> getFontNamesFromLocation(String locationPattern) {
try {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader);
return Arrays.stream(resources)
.map(
resource -> {
try {
String filename = resource.getFilename();
if (filename != null) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex != -1) {
String name = filename.substring(0, lastDotIndex);
String extension = filename.substring(lastDotIndex + 1);
return new FontResource(name, extension);
}
}
return null;
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontLoadingFailed",
"Error processing font file",
e);
}
})
.filter(Objects::nonNull)
.toList();
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontDirectoryReadFailed", "Failed to read font directory", e);
}
}
// Data classes
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
}
@Data
public static class LicensesData {
private List<Dependency> dependencies;
}
@Data
public static class PipelineData {
private List<Map<String, String>> pipelineConfigsWithNames;
private List<String> pipelineConfigs;
}
@Data
public static class SignData {
private List<SignatureFile> signatures;
private List<FontResource> fonts;
}
@Data
public static class OcrData {
private List<String> languages;
}
@Data
public static class FontResource {
private String name;
private String extension;
private String type;
public FontResource(String name, String extension) {
this.name = name;
this.extension = extension;
this.type = getFormatFromExtension(extension);
}
private static String getFormatFromExtension(String extension) {
switch (extension) {
case "ttf":
return "truetype";
case "woff":
return "woff";
case "woff2":
return "woff2";
case "eot":
return "embedded-opentype";
case "svg":
return "svg";
default:
return "";
}
}
}
}

View File

@@ -110,14 +110,14 @@ public class ConfigController {
}
@GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) {
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam(name = "endpoint") String endpoint) {
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
return ResponseEntity.ok(enabled);
}
@GetMapping("/endpoints-enabled")
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
@RequestParam String endpoints) {
@RequestParam(name = "endpoints") String endpoints) {
Map<String, Boolean> result = new HashMap<>();
String[] endpointArray = endpoints.split(",");
for (String endpoint : endpointArray) {