Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
run: mvn -B test
- name: Integration tests
run: mvn -B verify
- name: Export OpenAPI spec
run: mvn exec:java@export-openapi
- name: Verify committed OpenAPI spec is up to date
run: git diff --exit-code -- ../frontend/openapi.json
- name: Update third-party licenses (Renovate PRs only)
if: >
github.event_name == 'pull_request' &&
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
echo "PUBLIC_REPOSITORY_URL=https://github.com/${GITHUB_REPOSITORY}" >> "$GITHUB_ENV"
- name: Clean install
run: npm run clean-install
- name: Generate API client
run: npm run api:generate
- name: Update third party licenses (Renovate PRs only)
if: >
github.event_name == 'pull_request' &&
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/publish-test-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
echo "PUBLIC_REPOSITORY_URL=https://github.com/${GITHUB_REPOSITORY}" >> "$GITHUB_ENV"
- name: Clean install
run: npm run clean-install
- name: Generate API client
run: npm run api:generate
- name: Build
env:
PUBLIC_APP_VERSION: ${{ env.APP_VERSION }}
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,30 @@ When the backend is running, Swagger UI is available at:

- `http://localhost:8080/swagger-ui.html`

### Generated frontend API client

The frontend calls the backend through a typed client generated by
[`@hey-api/openapi-ts`](https://heyapi.dev/) from the backend's OpenAPI spec. To keep CI and
Docker builds independent of a running backend, the spec is committed as a contract at
`frontend/openapi.json` and the client (`frontend/src/lib/api/generated`, git-ignored) is
generated from it:

```bash
cd frontend
npm run api:generate # writes src/lib/api/generated from ./openapi.json
```

When backend routes or DTOs change, regenerate and commit the contract from the backend module:

```bash
cd backend
mvn exec:java@export-openapi
# writes ../frontend/openapi.json - commit the result
```

Backend CI runs the same export and fails if `frontend/openapi.json` is out of date, so the
committed contract can never drift from the controllers.

## Development Workflows

### Backend
Expand All @@ -140,6 +164,7 @@ mvn -B verify
```bash
cd frontend
npm run clean-install
npm run api:generate
npm run test
npm run lint
npm run build
Expand Down
17 changes: 17 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,23 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<executions>
<execution>
<id>export-openapi</id>
<phase>none</phase>
<goals><goal>java</goal></goals>
<configuration>
<mainClass>org.rdfarchitect.OpenApiSpecExport</mainClass>
<classpathScope>runtime</classpathScope>
<cleanupDaemonThreads>false</cleanupDaemonThreads>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
<pluginManagement>
<plugins>
Expand Down
150 changes: 150 additions & 0 deletions backend/src/main/java/org/rdfarchitect/OpenApiSpecExport.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2024-2026 SOPTIM AG
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package org.rdfarchitect;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.web.client.RestClient;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* * Exports the springdoc OpenAPI document to a deterministic JSON file so the frontend can
* generate * its API client offline (in CI and in Docker) without a running backend. * *
*
* <p>Runs as a standalone Maven goal: {@code mvn compile exec:java@export-openapi}. The output *
* path defaults to {@code ../frontend/openapi.json} (relative to the backend module) and can be *
* overridden with the first program argument or {@code -Dopenapi.export.path=...}. * *
*
* <p>Boots the full Spring application on an ephemeral port (server.port=0), fetches the spec, *
* normalizes it, writes it to disk, and shuts the context down. CI runs this and diffs the result *
* so the committed spec can never drift from the controllers.
*/
public final class OpenApiSpecExport {

private static final String DEFAULT_OUTPUT_PATH = "../frontend/openapi.json";

private OpenApiSpecExport() {}

public static void main(String[] args) throws IOException {
// Use an ephemeral port so we never collide with a developer's running backend.
System.setProperty("server.port", "0");

try (ConfigurableApplicationContext ctx = SpringApplication.run(Launcher.class, args)) {
if (!(ctx instanceof WebServerApplicationContext webCtx)) {
throw new IllegalStateException(
"Expected a WebServerApplicationContext but got " + ctx.getClass());
}
int port = Objects.requireNonNull(webCtx.getWebServer()).getPort();
String rawSpec =
RestClient.create()
.get()
.uri("http://localhost:" + port + "/v3/api-docs")
.retrieve()
.body(String.class);

if (rawSpec == null || !rawSpec.contains("\"openapi\"")) {
throw new IllegalStateException(
"Unexpected response from /v3/api-docs: " + rawSpec);
}

String formattedSpec = formatDeterministically(rawSpec);

Path target = resolveTargetPath(args);
Path parent = target.toAbsolutePath().normalize().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(target, formattedSpec, StandardCharsets.UTF_8);

System.out.println("Wrote OpenAPI spec to " + target.toAbsolutePath().normalize());
}
}

private static Path resolveTargetPath(String[] args) {
if (args.length > 0 && !args[0].isBlank()) {
return Path.of(args[0]);
}
return Path.of(System.getProperty("openapi.export.path", DEFAULT_OUTPUT_PATH));
}

/**
* * Re-serializes the spec with sorted keys and a fixed {@code \n} indenter so the output is *
* stable across runs and operating systems. The {@code servers} entry is dropped because it *
* reflects the request URL and is overridden at runtime by the frontend client ({@code *
* src/lib/api/hey-api.ts}); keeping it adds no value to the committed contract. The {@code *
* info.version} is pinned to {@code 0.0.0} because it is derived from the build version (git *
* describe), which changes on every commit and would otherwise make the committed contract *
* drift on every build.
*/
private static String formatDeterministically(String rawSpec) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);

DefaultIndenter indenter = new DefaultIndenter(" ", "\n");
DefaultPrettyPrinter printer = new DefaultPrettyPrinter();
printer.indentObjectsWith(indenter);
printer.indentArraysWith(indenter);

Map<String, Object> spec = mapper.readValue(rawSpec, new TypeReference<>() {});
spec.remove("servers");
if (spec.get("info") instanceof Map<?, ?> info) {
@SuppressWarnings("unchecked")
Map<String, Object> writableInfo = (Map<String, Object>) info;
writableInfo.put("version", "0.0.0");
}
sortRequiredArrays(spec);
return mapper.writer(printer).writeValueAsString(spec) + "\n";
}

/**
* * Sorts every {@code required} string array in the document. springdoc derives this list from
* * field reflection, whose order is not guaranteed across JVMs; {@code required} is an
* unordered * set in OpenAPI, so sorting it makes the committed contract robust against that
* variance.
*/
private static void sortRequiredArrays(Object node) {
if (node instanceof Map<?, ?> map) {
Object required = map.get("required");
if (required instanceof List<?> values
&& values.stream().allMatch(String.class::isInstance)) {
List<String> sorted = values.stream().map(String.class::cast).sorted().toList();
@SuppressWarnings("unchecked")
Map<String, Object> writableMap = (Map<String, Object>) map;
writableMap.put("required", sorted);
}
map.values().forEach(OpenApiSpecExport::sortRequiredArrays);
} else if (node instanceof List<?> values) {
values.forEach(OpenApiSpecExport::sortRequiredArrays);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
Expand Down Expand Up @@ -57,10 +58,16 @@ public class OntologyKnownInformationRESTController {
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = OntologyField.class)))
array =
@ArraySchema(
schema =
@Schema(
implementation =
OntologyField
.class))))
})
@GetMapping
public List<OntologyField> getOntology(
public List<OntologyField> getKnownOntologyFields(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class SchemaComparisonFromFilesRESTController {
@Operation(
summary = "compare schemas",
description = "Compare two given graphs",
tags = {"comparison"},
responses = {
@ApiResponse(
responseCode = "200",
Expand All @@ -68,7 +69,7 @@ public class SchemaComparisonFromFilesRESTController {
.class))))
})
@PostMapping
public List<TriplePackageChange> compareSchemas(
public List<TriplePackageChange> compareSchemasFromFiles(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public class SchemaValidationFromFileRESTController {
SchemaValidationReportDTO.class)))
})
@PostMapping
public SchemaValidationReportDTO validateSchema(
public SchemaValidationReportDTO validateFile(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import lombok.RequiredArgsConstructor;

import org.rdfarchitect.api.controller.Response;
import org.rdfarchitect.api.dto.DatasetDTO;
import org.rdfarchitect.services.select.ListDatasetsUseCase;
import org.rdfarchitect.services.update.dataset.DeleteDatasetUseCase;
import org.slf4j.Logger;
Expand All @@ -50,11 +51,12 @@ public class DatasetRESTController {

@Operation(
summary = "List datasets",
description = "Lists all non-snapshots datasets",
description =
"Lists all non-snapshots datasets including their prefixes and read-only status.",
tags = {"dataset"},
responses = {@ApiResponse(responseCode = "200")})
@GetMapping
public List<String> listDatasets(
public List<DatasetDTO> listDatasets(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ public class GlobalClassLayoutDataRESTController {
private final UpdateClassPositionsUseCase updateClassPositionsUseCase;

@Operation(
summary = "updates class positions",
summary = "updates class positions on dataset level",
description =
"Updates the positions for all the classes provided in the request body with the provided coordinates.",
tags = {"diagram", "layout", "class"})
@PutMapping
public String updateClassPositions(
public String updateDatasetClassPositions(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(value = "origin", required = false, defaultValue = "unknown")
String originURL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@

package org.rdfarchitect.api.controller.datasets.diagrams;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;

import lombok.RequiredArgsConstructor;

Expand All @@ -44,8 +49,26 @@ public class AllCustomDatasetDiagramsRESTController {

private final GetCustomDiagramsUseCase getCustomDiagramsUseCase;

@Operation(
summary = "list custom diagrams for dataset",
description =
"Returns a list of all custom diagrams for the specified dataset. Each diagram includes its ID, name, and other relevant information.",
tags = {"diagram"},
responses =
@ApiResponse(
responseCode = "200",
content =
@Content(
mediaType = "application/json",
array =
@ArraySchema(
schema =
@Schema(
implementation =
CustomDiagram
.class)))))
@GetMapping
public List<CustomDiagram> getCustomDiagramList(
public List<CustomDiagram> getCustomDatasetDiagramList(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class CrossProfileDiagramIDRESTController {
private final DatabasePort databasePort;

@GetMapping
public String getCrossProfileRenderingData(
public String getCrossProfileDiagramId(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class CrossProfileDiagramRestController {
private final GetCustomDiagramsUseCase getCustomDiagramsUseCase;

@GetMapping
public CrossProfileDiagramDTO getCrossProfileRenderingData(
public CrossProfileDiagramDTO getCrossProfileDiagram(
@Parameter(description = "The name/url of the inquirer.")
@RequestHeader(
value = HttpHeaders.ORIGIN,
Expand Down
Loading