diff --git a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/api/DataStructures.scala b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/api/DataStructures.scala
index 539fa6d308d..26e30096a38 100644
--- a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/api/DataStructures.scala
+++ b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/api/DataStructures.scala
@@ -206,12 +206,13 @@ object SupportedApiVersion {
/*
* API version are incremented at least one time for each Rudder version.
* We try to avoid the case where a breaking change would incur an API increment.
- * We deprecate an API is not the last, and we delete deprecated version on a major
+ * We deprecate an API that is not the last, and we delete deprecated version on a major
* release, for all but the last of previous major branch.
*/
lazy val apiVersions: List[ApiVersion] = {
ApiVersion(21, deprecated = true) :: // rudder 8.3 - zio-json, api accounts,new authorization model
- ApiVersion(22, deprecated = false) :: // rudder 9.0 - campaigns, ....
+ ApiVersion(22, deprecated = true) :: // rudder 9.0 - campaigns, ....
+ ApiVersion(23, deprecated = false) :: // rudder 9.1 - benchmarks csv, ....
Nil
}
diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/ApiDatastructures.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/ApiDatastructures.scala
index d01daf98cab..fe7ad5ca31a 100644
--- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/ApiDatastructures.scala
+++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/ApiDatastructures.scala
@@ -275,6 +275,7 @@ trait StartsAtVersion19 extends EndpointSchema { val versions: ApiV.From = ApiV.
trait StartsAtVersion20 extends EndpointSchema { val versions: ApiV.From = ApiV.From(20) } // Rudder 8.2
trait StartsAtVersion21 extends EndpointSchema { val versions: ApiV.From = ApiV.From(21) } // Rudder 8.3
trait StartsAtVersion22 extends EndpointSchema { val versions: ApiV.From = ApiV.From(22) } // Rudder 9.0
+trait StartsAtVersion23 extends EndpointSchema { val versions: ApiV.From = ApiV.From(23) } // Rudder 9.1
// utility extension trait to define the kind of API
trait PublicApi extends EndpointSchema { val kind: ApiKind.Public.type = ApiKind.Public }
diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala
index 5588d932911..10f63ae7065 100644
--- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala
+++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala
@@ -40,9 +40,11 @@ package com.normation.rudder.rest
import com.normation.errors.*
import com.normation.rudder.domain.logger.ApiLogger
import com.normation.rudder.rest.lift.DefaultParams
+import com.normation.utils.Csv
import com.normation.zio.*
import net.liftweb.http.InMemoryResponse
import net.liftweb.http.LiftResponse
+import net.liftweb.http.PlainTextResponse
import scala.collection.immutable
import zio.json.*
@@ -265,6 +267,33 @@ object RudderJsonResponse {
def toLiftResponseList(params: DefaultParams, schema: EndpointSchema)(using encoder: JsonEncoder[A]): LiftResponse = {
toLiftResponseList(params, ResponseSchema.fromSchema(schema))
}
+
+ def toLiftResponseCsv(params: DefaultParams, schema: ResponseSchema, id: IdTrace[A])(using
+ csv: Csv[A],
+ header: Csv.Header[A]
+ ): LiftResponse = {
+ import com.normation.utils.Csv.*
+ given prettify: Boolean = params.prettify
+ result
+ .fold(
+ err => {
+ ApiLogger.ResponseError.info(err.fullMsg)
+ internalError(None, schema, err.fullMsg)
+ },
+ a => PlainTextResponse(a.toList.toCsv)
+ )
+ .runNow
+ }
+
+ def toLiftResponseCsv(params: DefaultParams, schema: EndpointSchema, id: Option[String])(using
+ csv: Csv[A],
+ header: Csv.Header[A]
+ ): LiftResponse = toLiftResponseCsv(params, ResponseSchema.fromSchema(schema), IdTrace.Const[A](id))
+
+ def toLiftResponseCsv(params: DefaultParams, schema: EndpointSchema, id: A => Option[String])(using
+ csv: Csv[A],
+ header: Csv.Header[A]
+ ): LiftResponse = toLiftResponseCsv(params, ResponseSchema.fromSchema(schema), IdTrace.Success[A](id))
}
// ADT that matches error or success to determine the id value to use/compute
@@ -371,10 +400,9 @@ object RudderJsonResponse {
): LiftResponse = {
toLiftResponseZeroEither(params, ResponseSchema.fromSchema(schema), IdTrace.Success[A](id))(using ev)
}
-
}
// when you don't have any response, just a success
- extension (result: IOResult[Unit]) {
+ extension (result: IOResult[Unit]) {
def toLiftResponseZeroUnit(params: DefaultParams, schema: ResponseSchema): LiftResponse = {
given prettify: Boolean = params.prettify
result
diff --git a/webapp/sources/utils/pom.xml b/webapp/sources/utils/pom.xml
index 1914d795c58..d188211b4c3 100644
--- a/webapp/sources/utils/pom.xml
+++ b/webapp/sources/utils/pom.xml
@@ -41,6 +41,14 @@ limitations under the License.
commons-io
commons-io
+
+ org.apache.commons
+ commons-csv
+
+
+ org.apache.commons
+ commons-lang3
+
net.liftweb
lift-common_2.13
diff --git a/webapp/sources/utils/src/main/scala/com/normation/utils/Csv.scala b/webapp/sources/utils/src/main/scala/com/normation/utils/Csv.scala
new file mode 100644
index 00000000000..32b5b67f860
--- /dev/null
+++ b/webapp/sources/utils/src/main/scala/com/normation/utils/Csv.scala
@@ -0,0 +1,131 @@
+/*
+ *************************************************************************************
+ * Copyright 2025 Normation SAS
+ *************************************************************************************
+ *
+ * This file is part of Rudder.
+ *
+ * Rudder is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * In accordance with the terms of section 7 (7. Additional Terms.) of
+ * the GNU General Public License version 3, the copyright holders add
+ * the following Additional permissions:
+ * Notwithstanding to the terms of section 5 (5. Conveying Modified Source
+ * Versions) and 6 (6. Conveying Non-Source Forms.) of the GNU General
+ * Public License version 3, when you create a Related Module, this
+ * Related Module is not considered as a part of the work and may be
+ * distributed under the license agreement of your choice.
+ * A "Related Module" means a set of sources files including their
+ * documentation that, without modification of the Source Code, enables
+ * supplementary functions or services in addition to those offered by
+ * the Software.
+ *
+ * Rudder is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Rudder. If not, see .
+
+ *
+ *************************************************************************************
+ */
+
+package com.normation.utils
+
+import enumeratum.EnumEntry
+import java.time.ZonedDateTime
+import magnolia1.CaseClass
+import magnolia1.ProductDerivation
+import org.joda.time.DateTime
+import scala.language.experimental.macros
+
+/**
+ * Generic CSV typeclass using magnolia derivation, from examples :
+ * A primitive value maps to a singleton list, case classes map to a list with the same shape
+ */
+trait Csv[A] {
+ def apply(a: A): List[String]
+}
+
+object Csv extends ProductDerivation[Csv] {
+ import org.apache.commons.csv.*
+ import org.apache.commons.lang3.StringUtils
+
+ def empty: List[String] = List("")
+
+ /**
+ * Proof of header for CSV instances.
+ * The header for a CSV field is always the case class field name, formatted from camelCase to it's human readable version.
+ * Therefore using a type class instance to customize the header will not work.
+ */
+ trait Header[A] {
+ def header: List[String] = Nil
+ }
+
+ object Header extends ProductDerivation[Header] {
+ // Derive headers names using the field names, an empty (default) header means a fresh column should be added from field
+ def join[A](ctx: CaseClass[Header, A]): Header[A] = new Header[A] {
+ override def header: List[String] = {
+ ctx.parameters
+ .flatMap(p => {
+ val fields = p.typeclass.header
+ if (fields.isEmpty) p.label :: Nil
+ else fields
+ })
+ .map(field => StringUtils.capitalize(StringUtils.splitByCharacterTypeCamelCase(field).mkString(" ")))
+ .toList
+ }
+ }
+
+ given headerOption[A: Header]: Header[Option[A]] with { override def header: List[String] = summon[Header[A]].header }
+
+ // utility instances for known sum types, since their header can only be guessed from the corresponding field name in the case class
+ given headerEnumEntry[A <: EnumEntry]: Header[A] with {}
+
+ // instance that must exist, but that make no sense unless the `deriveHeader` macro is used on a case class (derives Header)
+ given headerAnyVal[A <: AnyVal]: Header[A] with {}
+
+ // custom, one-column instances
+ given headerString: Header[String] with {}
+ given headerDateTime: Header[DateTime] with {}
+ given headerZonedDateTime: Header[ZonedDateTime] with {}
+ }
+
+ def join[A](ctx: CaseClass[Csv, A]): Csv[A] =
+ (a: A) => ctx.parameters.foldLeft(List[String]())((acc, p) => acc ++ p.typeclass(p.deref(a)))
+
+ // Common instances
+ given [A](using Csv[A]): Csv[Option[A]] = (a: Option[A]) => a.fold(empty)(summon[Csv[A]].apply)
+
+ given csvString: Csv[String] = (a: String) => List(a)
+ given csvInt: Csv[Int] = instance(_.toString)
+ given csvEnumEntry[A <: EnumEntry]: Csv[A] = instance(_.entryName)
+ given csvDateTime: Csv[DateTime] = instance(_.toString)
+ given csvZonedDateTime: Csv[ZonedDateTime] = instance(DateFormaterService.serializeZDT)
+
+ def instance[A](f: A => String): Csv[A] = (a: A) => csvString.apply(f(a))
+
+ // TODO: change to "syntax"
+ /**
+ * The CSV output with its headers and lines for results
+ */
+ extension [A: Csv: Csv.Header](results: Seq[A]) {
+ def toCsv: String = {
+ val header = summon[Header[A]].header
+ val instance = summon[Csv[A]]
+ val out = new StringBuilder()
+ csvFormat.printRecord(out.underlying, header*)
+ results.foreach(l => csvFormat.printRecord(out.underlying, instance.apply(l)*))
+ out.toString
+ }
+ }
+
+ // use "," , quote everything with ", line separator is \n
+ def csvFormat: CSVFormat = CSVFormat.DEFAULT.builder().setQuoteMode(QuoteMode.ALL).setRecordSeparator("\n").get()
+
+}