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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions webapp/sources/utils/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ limitations under the License.
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
Copy link
Member

Choose a reason for hiding this comment

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

is there a lot of work to remove commans-lang if we have commons-lang3 ?

  • commons-lang is in pom.xml
  • we aleady have commons-lang3 in rudder/rudder-templates/pom.xml (so we should already have done that work likely)

</dependency>
<dependency>
<groupId>net.liftweb</groupId>
<artifactId>lift-common_2.13</artifactId>
Expand Down
131 changes: 131 additions & 0 deletions webapp/sources/utils/src/main/scala/com/normation/utils/Csv.scala
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

*
*************************************************************************************
*/

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

}