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() + +}