Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ jobs:
- name: Execute Tests
run: ./gradlew test

- name: Upload test report
uses: actions/upload-artifact@v4
if: failure()
with:
name: backend-test-report
path: backend/build/reports/tests/test/

- name: Check Format And Lint
run: ./gradlew ktlintCheck

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ jobs:
run: echo "branchTag=${TAG##*:}" >> $GITHUB_OUTPUT

- name: Wait for website Docker image
uses: lewagon/wait-on-check-action@v1.6.0
uses: lewagon/wait-on-check-action@v1.6.1
with:
ref: ${{ github.sha }}
check-name: Build Website Docker Image
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
- name: Wait for backend Docker image
uses: lewagon/wait-on-check-action@v1.6.0
uses: lewagon/wait-on-check-action@v1.6.1
with:
ref: ${{ github.sha }}
check-name: Build Backend Docker Image
Expand Down
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# AGENTS.md — Coding Agent Guidelines

This monorepo contains two packages:
- **`website/`** — Astro + React frontend (TypeScript), see `website/AGENTS.md`
- **`backend/`** — Kotlin + Spring Boot backend, see `backend/AGENTS.md`

---

## Repository Structure

```
/
├── website/ # Astro/React frontend
├── backend/ # Kotlin/Spring Boot backend
├── docs/ # Architecture documentation (arc42)
└── docker-compose.yml
```

---

## Commit Messages

Follow [Conventional Commits](https://www.conventionalcommits.org):
```
feat: add new organism dashboard
fix: correct date range calculation
chore: update dependencies
```
- PR titles become the squash-merge commit message — they must also follow conventional commits.
- Messages should explain **why** a change was made, not just what.

---

## Architecture Notes

- **State in URL**: View state (filters, selected variants) is stored as query parameters so pages are bookmarkable and shareable.
- **LAPIS**: All genomic data queries go directly from the browser to LAPIS instances — they do not go through the backend.
- **Backend**: Only handles subscriptions and collections features; authentication is handled in the frontend (Astro middleware) and proxied to the backend.
- **Organisms**: Adding a new organism requires entries in `website/src/types/Organism.ts`, `website/src/views/`, and corresponding Astro pages.
73 changes: 73 additions & 0 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# AGENTS.md — Backend

Kotlin + Spring Boot backend. All commands run from `backend/`. Docker must be running (tests use Testcontainers).

---

## Build & Dev Commands

```bash
./gradlew build # Build
./gradlew test # Run all tests
./gradlew bootRun --args='--spring.profiles.active=local-db,dashboards-prod' # Run locally
```

The backend will be available at `http://localhost:8080`. Swagger UI at `http://localhost:8080/swagger-ui/index.html`.
The database from the top-level Docker Compose file needs to be running.

## Running a Single Test

```bash
# Run a single test class:
./gradlew test --tests "org.genspectrum.dashboardsbackend.controller.SubscriptionsControllerTest"

# Run a single test method (use the full backtick-quoted name):
./gradlew test --tests "org.genspectrum.dashboardsbackend.controller.SubscriptionsControllerTest.GIVEN I created a subscription WHEN getting subscriptions THEN contains created subscription"
```

## Lint

```bash
./gradlew ktlintCheck
./gradlew ktlintFormat
```

---

## Code Style

### Style
- Enforced by **ktlint** (version 1.4.1). Run `./gradlew ktlintFormat` to auto-fix.
- Follow standard Kotlin idioms: data classes, sealed interfaces, extension functions.

### Naming
- Classes/interfaces: `PascalCase`
- Functions/variables: `camelCase`
- Constants: `UPPER_CASE`
- Test method names use backtick syntax describing behavior:
```kotlin
@Test
fun `GIVEN some state WHEN action THEN expected result`() { ... }
```

### Logging
Use `kotlin-logging` (`io.github.microutils:kotlin-logging-jvm`):
```kotlin
private val log = KotlinLogging.logger {}
log.info { "message" }
```

(There should be one global logger instance that can be reused everywhere)

### Error Handling
- Throw typed exceptions: `BadRequestException`, `NotFoundException`, `ForbiddenException`.
- The global `ExceptionHandler` maps these to appropriate HTTP status codes with `ProblemDetail` bodies.
- Log unexpected exceptions at `error` level; expected/handled exceptions at `info` or `warn`.

### Testing
- Use `@SpringBootTest` + `@AutoConfigureMockMvc` for integration tests.
- Since the API is a relatively thin layer around the database, most tests should focus on interacting with the code via the endpoints.
Only add dedicated unit tests for isolated logic that is more complex.
- Use **MockK** (via `springmockk`) instead of Mockito.
- Use **Testcontainers** for database tests (Docker required).
- Tests are organized by controller/feature, not by test type.
7 changes: 7 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ Run tests:
./gradlew test
```

## Authentication
While the backend uses user IDs internally, for example to track owners
of collections or subscriptions, it doesn't handle authentication of users.
This is done in the frontend. All requests are proxied through the frontend
server, where authentication is handled, and then the authenticated requests
are sent to the backend.

## Logging
Logs to rotating files are stored in `./logs` and written to stdout.
In the Docker container, log files are stored in `/workspace/logs`
Expand Down
24 changes: 20 additions & 4 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.ByteArrayOutputStream

plugins {
kotlin("jvm") version "2.3.20"
kotlin("plugin.spring") version "2.3.20"
id("org.springframework.boot") version "3.4.0"
id("org.springframework.boot") version "3.5.13"
id("io.spring.dependency-management") version "1.1.7"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("org.springdoc.openapi-gradle-plugin") version "1.9.0"
Expand All @@ -27,11 +28,13 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
implementation("org.flywaydb:flyway-database-postgresql:11.0.0")
implementation("org.postgresql:postgresql:42.7.10")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.56.0")
implementation("org.jetbrains.exposed:exposed-json:0.56.0")
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:1.2.0")
implementation("org.jetbrains.exposed:exposed-json:1.2.0")
implementation("org.jetbrains.exposed:exposed-jdbc:1.2.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

Expand All @@ -58,7 +61,20 @@ kotlin {
}
}

val checkDockerAvailable by tasks.registering(Exec::class) {
commandLine("docker", "info")
standardOutput = ByteArrayOutputStream()
errorOutput = ByteArrayOutputStream()
isIgnoreExitValue = true
doLast {
if (executionResult.get().exitValue != 0) {
throw GradleException("Docker is not available. Please start Docker before running tests.")
}
}
}

tasks.withType<Test> {
dependsOn(checkDockerAvailable)
useJUnitPlatform()
testLogging {
events("FAILED")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.genspectrum.dashboardsbackend.api

import io.swagger.v3.oas.annotations.media.Schema
import kotlin.time.Instant

@Schema(
description = "A collection of variants",
Expand All @@ -11,7 +12,9 @@ import io.swagger.v3.oas.annotations.media.Schema
"ownedBy": "user123",
"organism": "covid",
"description": "A collection of interesting variants",
"variants": []
"variants": [],
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z"
}
""",
)
Expand All @@ -22,6 +25,8 @@ data class Collection(
val organism: String,
val description: String?,
val variants: List<Variant>,
val createdAt: Instant,
val updatedAt: Instant,
)

@Schema(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema
import org.genspectrum.dashboardsbackend.api.Variant.FilterObjectVariant
import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant
import kotlin.time.Instant

enum class QueryVariantType {
@JsonProperty("query")
Expand Down Expand Up @@ -44,7 +45,9 @@ sealed interface Variant {
"name": "BA.2 in USA",
"description": "BA.2 lineage cases in USA",
"countQuery": "country='USA' & lineage='BA.2'",
"coverageQuery": "country='USA'"
"coverageQuery": "country='USA'",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z"
}
""",
)
Expand All @@ -55,6 +58,8 @@ sealed interface Variant {
val description: String?,
val countQuery: String,
val coverageQuery: String? = null,
val createdAt: Instant,
val updatedAt: Instant,
) : Variant {
val type: QueryVariantType = QueryVariantType.QUERY
}
Expand All @@ -70,7 +75,9 @@ sealed interface Variant {
"description": "Key mutations for Omicron",
"filterObject": {
"aminoAcidMutations": ["S:N501Y", "S:E484K", "S:K417N"]
}
},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z"
}
""",
)
Expand All @@ -80,6 +87,8 @@ sealed interface Variant {
val name: String,
val description: String?,
val filterObject: FilterObject,
val createdAt: Instant,
val updatedAt: Instant,
) : Variant {
val type: FilterObjectVariantType = FilterObjectVariantType.FILTER_OBJECT
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import io.swagger.v3.oas.models.parameters.HeaderParameter
import org.flywaydb.core.Flyway
import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER
import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER_DESCRIPTION
import org.jetbrains.exposed.spring.autoconfigure.ExposedAutoConfiguration
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.spring.boot.autoconfigure.ExposedAutoConfiguration
import org.springdoc.core.customizers.OperationCustomizer
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@ data class DashboardsConfig(val organisms: Map<String, OrganismConfig>) {
throw BadRequestException("Organism '$organism' is not supported")
}
}

fun validateCollectionsEnabled(organism: String) {
if (!getOrganismConfig(organism).hasCollections) {
throw BadRequestException("Collections are not supported for organism '$organism'")
}
}
}

data class OrganismConfig(val lapis: LapisConfig, val externalNavigationLinks: List<ExternalNavigationLink>?)
data class OrganismConfig(
val lapis: LapisConfig,
val externalNavigationLinks: List<ExternalNavigationLink>?,
val hasCollections: Boolean = true,
)

data class LapisConfig(
val url: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.genspectrum.dashboardsbackend.config

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import org.springframework.boot.jackson.JsonComponent
import kotlin.time.Instant

@JsonComponent
class InstantSerializer : JsonSerializer<Instant>() {
override fun serialize(value: Instant, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(value.toString())
}
}

@JsonComponent
class InstantDeserializer : JsonDeserializer<Instant>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant = Instant.parse(p.valueAsString)
}
Loading
Loading