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
11 changes: 2 additions & 9 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,19 @@ repositories {
}

dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation("org.http4k:http4k-core:6.28.1.0")
implementation("org.http4k:http4k-server-jetty:6.28.1.0")
implementation("org.http4k:http4k-format-moshi:6.28.1.0")
implementation("com.natpryce:konfig:1.6.10.0")
implementation("com.squareup.moshi:moshi:1.15.2")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.10")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.10")
implementation("com.github.ben-manes.caffeine:caffeine:3.2.3")
implementation("ch.qos.logback:logback-classic:1.5.29")
implementation("ch.qos.logback.contrib:logback-json-classic:0.1.5")
implementation("de.gessnerfl.logback:logback-gson-formatter:0.1.0")
implementation("io.github.oshai:kotlin-logging:7.0.14")
testImplementation("io.kotest:kotest-assertions-core:6.1.3")
testImplementation("org.spekframework.spek2:spek-dsl-jvm:2.0.19")
testImplementation("io.mockk:mockk:1.14.9")
testRuntimeOnly("org.spekframework.spek2:spek-runner-junit5:2.0.19")
testImplementation("io.kotest:kotest-runner-junit5:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("com.karumi.kotlinsnapshot:core:2.3.0")
}
Expand All @@ -53,9 +48,7 @@ tasks.withType<ShadowJar> {
}

tasks.withType<Test> {
useJUnitPlatform {
includeEngines("spek2")
}
useJUnitPlatform()
}

tasks.register("dockerBuildProperties") {
Expand Down
8 changes: 1 addition & 7 deletions src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<layout class="ch.qos.logback.contrib.json.classic.JsonLayout">
<timestampFormat>yyyy-MM-dd'T'HH:mm:ss.SSSXXX</timestampFormat>
<jsonFormatter class="de.gessnerfl.logback.gson.GsonFormatter"/>
<appendLineSeparator>true</appendLineSeparator>
</layout>
</encoder>
<encoder class="ch.qos.logback.classic.encoder.JsonEncoder"/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check build.gradle.kts for logback version and dependencies
find . -name "build.gradle.kts" -type f | head -5

Repository: blindern/users-api

Length of output: 80


🏁 Script executed:

# Also check for build.gradle files
find . -name "build.gradle" -type f | head -5

Repository: blindern/users-api

Length of output: 44


🏁 Script executed:

# Search for logback dependencies in gradle files
rg "logback" -t gradle

Repository: blindern/users-api

Length of output: 210


🏁 Script executed:

# Read build.gradle.kts to check logback version
cat build.gradle.kts

Repository: blindern/users-api

Length of output: 2753


🏁 Script executed:

# Search for logback references in all files
rg "logback" -A 2 -B 2

Repository: blindern/users-api

Length of output: 870


🏁 Script executed:

# Check the actual logback.xml file
cat src/main/resources/logback.xml

Repository: blindern/users-api

Length of output: 398


🏁 Script executed:

# Check git history to see what changed
git log --oneline -n 20 -- src/main/resources/logback.xml build.gradle.kts 2>/dev/null || echo "Git history not available"

Repository: blindern/users-api

Length of output: 147


🏁 Script executed:

# Check git diff to see what was removed/added
git diff HEAD~5..HEAD -- src/main/resources/logback.xml build.gradle.kts 2>/dev/null | head -100 || echo "Git diff not available"

Repository: blindern/users-api

Length of output: 44


🌐 Web query:

logback JsonEncoder 1.5 introduction release notes

💡 Result:

JsonEncoder was not first introduced in Logback 1.5.x. It first appeared in the release notes for Logback 1.3.8 / 1.4.8 (2023-06-13): “Logback now ships with JsonEncoder … in Newline delimited JSON (ndjson) format.” [1]

In the Logback 1.5.0 release (2024-02-16), the release notes call out JsonEncoder enhancements: you can enable/disable all top-level JSON members, and an optional (disabled-by-default) "formattedMessage" member was added. [1]

Related 1.5.x JsonEncoder notes include:

  • 1.5.10 (2024-10-12): fix for invalid JSON when some fields (notably throwable) are disabled. [1]
  • 1.5.21 (2025-11-10): JsonEncoder made friendlier for subclassing. [1]

Source: Logback News / release notes page. [1]


🌐 Web query:

logback JsonLayout GsonFormatter vs JsonEncoder differences

💡 Result:

1) ch.qos.logback.contrib.json.classic.JsonLayout (logback-contrib)

  • Type: Layout (produces a JSON string); typically used via LayoutWrappingEncoder (i.e., it’s not an Encoder itself).
  • JSON library: Pluggable via jsonFormatter (commonly ch.qos.logback.contrib.jackson.JacksonJsonFormatter). [1]
  • Status / direction: The logback-contrib project was archived, and logback maintainers point people to Logback’s built-in JsonEncoder as an alternative. [2]

2) GsonFormatter (usually: de.gessnerfl.logback:logback-gson-formatter)

  • What it is: A third‑party JSON formatter module that plugs into logback-contrib’s JsonLayout to serialize via Google Gson (instead of Jackson). It depends on logback-json-core and gson. [3]
  • Scope: It does not replace JsonLayout; it’s only the formatter used by JsonLayout.

3) ch.qos.logback.classic.encoder.JsonEncoder (built into Logback)

  • Type: Encoder (writes bytes directly) and outputs JSON Lines (one JSON object per log event/line). [4][1]
  • Configuration model: Lots of boolean switches to include/exclude fields (timestamp, MDC, markers, arguments, throwable/stacktrace, etc.). [4]
  • Key practical difference vs JsonLayout: This is now the “native” Logback JSON option (no logback-contrib layout/formatter layering). [2][1]

Quick “which should I use?” rule of thumb

  • Want built-in Logback JSON with JSON Lines and configurable standard fields → Logback JsonEncoder. [4][1]
  • You already use logback-contrib JsonLayout and just want Gson instead of Jackson → JsonLayout + Gson formatter module. [3][1]
  • Need Logstash-focused JSON with rich structured logging features (providers/composite encoders, custom fields, etc.) → consider logstash-logback-encoder (LogstashEncoder / composite encoders). [5]

Sources
[1] Logback manual – Encoders / JsonEncoder section: https://logback.qos.ch/manual/encoders.html
[2] logback-user mailing list (May 17, 2024): logback-contrib archived; suggestion to use Logback JsonEncoder: https://mailman3.qos.ch/hyperkitty/list/logback-user%40qos.ch/thread/T2FRI3KDO3P4ASML464I77TKSBD6QFSE/
[3] Maven artifact for third-party Gson formatter (deps include logback-json-core + gson): https://mvnrepository.com/artifact/de.gessnerfl.logback/logback-gson-formatter
[4] Logback Javadoc / source overview for ch.qos.logback.classic.encoder.JsonEncoder (configurable included fields): https://logback.qos.ch/apidocs/ch.qos.logback.classic/ch/qos/logback/classic/encoder/JsonEncoder.html
[5] logstash-logback-encoder README (encoders/layouts, customization, composite encoders/providers): https://github.com/logfellow/logstash-logback-encoder


LGTM — clean migration to built-in JsonEncoder.

This removes the need for the unmaintained logback-json-classic and logback-gson-formatter dependencies. The JsonEncoder is built into Logback and fully supported by version 1.5.29.

Note: The JSON output format from JsonEncoder will likely differ from the previous JsonLayout+GsonFormatter combination (different field names, structure, timestamp format). If any log aggregation pipelines or dashboards depend on the specific JSON schema, they may need updating.

🤖 Prompt for AI Agents
In `@src/main/resources/logback.xml` at line 4, The logback configuration now uses
the built-in JsonEncoder (encoder
class="ch.qos.logback.classic.encoder.JsonEncoder") so remove the
now-unnecessary dependencies (logback-json-classic and logback-gson-formatter)
from the build configuration, ensure Logback is upgraded to v1.5.29+ in your
dependency manifest, and validate/adjust any log ingestion pipelines or
dashboards that relied on the prior JsonLayout+GsonFormatter schema (update
mappings or configure JsonEncoder properties to match expected field/timestamp
names if needed).

</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
Expand Down
302 changes: 132 additions & 170 deletions src/test/kotlin/no/foreningenbs/usersapi/MainSpec.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package no.foreningenbs.usersapi

import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import no.foreningenbs.usersapi.hmac.Hmac
import org.http4k.core.ContentType
Expand All @@ -12,195 +10,159 @@ import org.http4k.core.Status
import org.http4k.core.body.form
import org.http4k.core.with
import org.http4k.lens.Header.CONTENT_TYPE
import org.spekframework.spek2.Spek
import org.spekframework.spek2.lifecycle.CachingMode
import org.spekframework.spek2.style.specification.describe

object MainSpec : Spek({
describe("Main") {
describe("app") {
val ldap = createLdapMock()
val dataProvider = DataProvider(Config, ldap)
val app = app(ldap, dataProvider)

describe("hmac protected route") {
describe("request with missing hmac") {
val res by memoized(mode = CachingMode.EACH_GROUP) {
app(Request(Method.GET, "/users"))
}

it("should fail") {
res.status shouldBe Status.UNAUTHORIZED
class MainSpec :
DescribeSpec({
describe("Main") {
describe("app") {
val ldap = createLdapMock()
val dataProvider = DataProvider(Config, ldap)
val app = app(ldap, dataProvider)

describe("hmac protected route") {
describe("request with missing hmac") {
it("should fail") {
val res = app(Request(Method.GET, "/users"))
res.status shouldBe Status.UNAUTHORIZED
}
}
}

describe("request with bad hmac") {
val res by memoized(mode = CachingMode.EACH_GROUP) {
val hmac = Hmac(100, "some invalid key")
describe("request with bad hmac") {
it("should fail") {
val hmac = Hmac(100, "some invalid key")

fun Request.withHmac() = hmac.withHmac(this)
app(Request(Method.GET, "/users").withHmac())
}

it("should fail") {
res.status shouldBe Status.UNAUTHORIZED
fun Request.withHmac() = hmac.withHmac(this)
val res = app(Request(Method.GET, "/users").withHmac())
res.status shouldBe Status.UNAUTHORIZED
}
}
}
}

data class Row(
val title: String,
val requestBuilder: () -> Request,
val snapshotName: String,
val status: Status = Status.OK,
)

listOf(
Row(
"GET /groups",
{ Request(Method.GET, "/groups") },
"GET_groups_body",
),
Row(
"GET /group/beboer",
{ Request(Method.GET, "/group/beboer") },
"GET_group_beboer_body",
),
Row(
"GET /groups",
{ Request(Method.GET, "/groups") },
"GET_groups_body",
),
Row(
"GET /group/beboer",
{ Request(Method.GET, "/group/beboer") },
"GET_group_beboer_body",
),
Row(
"GET /user/unknown",
{ Request(Method.GET, "/user/unknown") },
"GET_user_unknown_body",
Status.NOT_FOUND,
),
Row(
"GET /user/halvargimnes",
{ Request(Method.GET, "/user/halvargimnes") },
"GET_user_halvargimnes_body",
),
Row(
"GET /user/halvargimnes?grouplevel=2",
{ Request(Method.GET, "/user/halvargimnes?grouplevel=2") },
"GET_user_halvargimnes_grouplevel_2_body",
),
Row(
"GET /users",
{ Request(Method.GET, "/users") },
"GET_users_body",
),
Row(
"GET /users?grouplevel=1",
{ Request(Method.GET, "/users?grouplevel=1") },
"GET_users_grouplevel_1_body",
),
Row(
"GET /users?emails=example%40example.com",
{ Request(Method.GET, "/users?emails=example%40example.com") },
"GET_users_email_no_match_body",
),
Row(
"GET /users?emails=halvargimnes%40foreningenbs.no",
{ Request(Method.GET, "/users?emails=halvargimnes%40foreningenbs.no") },
"GET_users_email_with_match_body",
),
).map { row ->
describe(row.title) {
val res by memoized(mode = CachingMode.EACH_GROUP) {
app(row.requestBuilder().withHmac())
}

it("should return expected status") {
res.status shouldBe row.status
}

it("should return expected body") {
res.bodyString() matchWithSnapshot row.snapshotName
data class Row(
val title: String,
val requestBuilder: () -> Request,
val snapshotName: String,
val status: Status = Status.OK,
)

listOf(
Row(
"GET /groups",
{ Request(Method.GET, "/groups") },
"GET_groups_body",
),
Row(
"GET /group/beboer",
{ Request(Method.GET, "/group/beboer") },
"GET_group_beboer_body",
),
Row(
"GET /user/unknown",
{ Request(Method.GET, "/user/unknown") },
"GET_user_unknown_body",
Status.NOT_FOUND,
),
Row(
"GET /user/halvargimnes",
{ Request(Method.GET, "/user/halvargimnes") },
"GET_user_halvargimnes_body",
),
Row(
"GET /user/halvargimnes?grouplevel=2",
{ Request(Method.GET, "/user/halvargimnes?grouplevel=2") },
"GET_user_halvargimnes_grouplevel_2_body",
),
Row(
"GET /users",
{ Request(Method.GET, "/users") },
"GET_users_body",
),
Row(
"GET /users?grouplevel=1",
{ Request(Method.GET, "/users?grouplevel=1") },
"GET_users_grouplevel_1_body",
),
Row(
"GET /users?emails=example%40example.com",
{ Request(Method.GET, "/users?emails=example%40example.com") },
"GET_users_email_no_match_body",
),
Row(
"GET /users?emails=halvargimnes%40foreningenbs.no",
{ Request(Method.GET, "/users?emails=halvargimnes%40foreningenbs.no") },
"GET_users_email_with_match_body",
),
).map { row ->
describe(row.title) {
val res = app(row.requestBuilder().withHmac())

it("should return expected status") {
res.status shouldBe row.status
}

it("should return expected body") {
res.bodyString() matchWithSnapshot row.snapshotName
}
}
}
}

describe("POST /simpleauth using urlencoded form") {
describe("using invalid credentials") {
val res by memoized(mode = CachingMode.EACH_GROUP) {
val req =
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
.form("username", "somethingInvalid")
.form("password", "test1234")
.withHmac()
app(req)
describe("POST /simpleauth using urlencoded form") {
describe("using invalid credentials") {
it("should fail") {
val res =
app(
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
.form("username", "somethingInvalid")
.form("password", "test1234")
.withHmac(),
)
res.status shouldBe Status.UNAUTHORIZED
}
}

it("should fail") {
res.status shouldBe Status.UNAUTHORIZED
}
}

describe("using valid credentials") {
val res by memoized(mode = CachingMode.EACH_GROUP) {
val req =
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
.form("username", MOCK_AUTH_VALID_USERNAME)
.form("password", MOCK_AUTH_VALID_PASSWORD)
.withHmac()
app(req)
}
describe("using valid credentials") {
val res =
app(
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
.form("username", MOCK_AUTH_VALID_USERNAME)
.form("password", MOCK_AUTH_VALID_PASSWORD)
.withHmac(),
)

it("should be successful") {
res.status shouldBe Status.OK
}
it("should be successful") {
res.status shouldBe Status.OK
}

it("should return expected data") {
res.bodyString() matchWithSnapshot "POST_simpleauth_body"
it("should return expected data") {
res.bodyString() matchWithSnapshot "POST_simpleauth_body"
}
}
}
}

describe("POST /simpleauth using JSON body") {
describe("using valid credentials") {
val res by memoized(mode = CachingMode.EACH_GROUP) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter =
Moshi
.Builder()
.add(KotlinJsonAdapterFactory())
.build()
.adapter<Map<String, String>>(type)
describe("POST /simpleauth using JSON body") {
describe("using valid credentials") {
val body =
adapter.toJson(
mapOf(
"username" to MOCK_AUTH_VALID_USERNAME,
"password" to MOCK_AUTH_VALID_PASSWORD,
),
"""{"username":"$MOCK_AUTH_VALID_USERNAME","password":"$MOCK_AUTH_VALID_PASSWORD"}"""

val res =
app(
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_JSON)
.body(body)
.withHmac(),
)

val req =
Request(Method.POST, "/simpleauth")
.with(CONTENT_TYPE of ContentType.APPLICATION_JSON)
.body(body)
.withHmac()
app(req)
}

it("should be successful") {
res.status shouldBe Status.OK
}
it("should be successful") {
res.status shouldBe Status.OK
}

it("should return expected data") {
res.bodyString() matchWithSnapshot "POST_simpleauth_body"
it("should return expected data") {
res.bodyString() matchWithSnapshot "POST_simpleauth_body"
}
}
}
}
}
}
})
})
2 changes: 1 addition & 1 deletion src/test/kotlin/no/foreningenbs/usersapi/TestHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ val mockGroups =
),
),
mockAdminGroup,
).map { GroupRef(it.name) to it }.toMap()
).associate { GroupRef(it.name) to it }

fun createLdapMock(): Ldap =
spyk(Ldap(Config)).also {
Expand Down
Loading