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 -B -Dopenapi.export=true -Dtest=OpenApiSpecExportTest -DfailIfNoTests=false test
- 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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,35 @@ 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 -B -Dopenapi.export=true -Dtest=OpenApiSpecExportTest -DfailIfNoTests=false test
# 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. To regenerate the client against a
live backend instead of the committed file, set `OPENAPI_INPUT`:

```bash
OPENAPI_INPUT=http://localhost:8080/v3/api-docs npm run api:generate
```

## Development Workflows

### Backend
Expand All @@ -138,6 +167,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
129 changes: 129 additions & 0 deletions backend/src/test/java/org/rdfarchitect/api/OpenApiSpecExportTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* 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.api;

import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;

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.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;

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

/**
* 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>Disabled by default; enabled with {@code -Dopenapi.export=true}. The output path defaults to
* {@code ../frontend/openapi.json} (relative to the backend module) and can be overridden with
* {@code -Dopenapi.export.path=...}. Backend CI runs this and diffs the result so the committed
* spec can never drift from the controllers.
*
* <p>Uses the default (mock) web environment and builds {@link MockMvc} from the context, matching
* the other {@code @SpringBootTest}s. A real server environment is avoided on purpose: it would
* start the embedded container and eagerly instantiate {@code @ServletComponentScan} components.
*/
@SpringBootTest
@EnabledIfSystemProperty(named = "openapi.export", matches = "true")
class OpenApiSpecExportTest {

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

@Autowired private WebApplicationContext webApplicationContext;

@Test
void exportsOpenApiSpec() throws Exception {
MockMvc mockMvc = webAppContextSetup(webApplicationContext).build();

String rawSpec =
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);

assertThat(rawSpec).contains("\"openapi\"");

String formattedSpec = formatDeterministically(rawSpec);

Path target = Path.of(System.getProperty("openapi.export.path", DEFAULT_OUTPUT_PATH));
Path parent = target.toAbsolutePath().normalize().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(target, formattedSpec, StandardCharsets.UTF_8);
}

/**
* 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.
*/
private static String formatDeterministically(String rawSpec) throws Exception {
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");
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(OpenApiSpecExportTest::sortRequiredArrays);
} else if (node instanceof List<?> values) {
values.forEach(OpenApiSpecExportTest::sortRequiredArrays);
}
}
}
8 changes: 7 additions & 1 deletion frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
LICENSES-THIRD-PARTY.md
LICENSES-THIRD-PARTY.md

# Generated from the backend OpenAPI spec (see openapi-ts.config.ts)
src/lib/api/generated

# Committed API contract, formatted by the backend exporter (OpenApiSpecExportTest)
openapi.json
3 changes: 3 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ RUN npm ci
# Copy source code
COPY . .

# Generate the API client from the committed OpenAPI spec (offline, no backend needed)
RUN npm run api:generate

# Build frontend
RUN npm run build

Expand Down
2 changes: 1 addition & 1 deletion frontend/openapi-ts.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
input: "http://localhost:8080/v3/api-docs",
input: process.env.OPENAPI_INPUT ?? "./openapi.json",
output: "src/lib/api/generated",
plugins: [
{
Expand Down
Loading
Loading