All Downloads are FREE. Search and download functionalities are using the official Maven repository.

pillars.Config.scala Maven / Gradle / Ivy

// Copyright (c) 2024-2024 by Raphaël Lemaitre and Contributors
// This software is licensed under the Eclipse Public License v2.0 (EPL-2.0).
// For more information see LICENSE or https://opensource.org/license/epl-2-0

package pillars

import cats.Show
import cats.effect.Async
import cats.effect.Resource
import cats.effect.Sync
import cats.syntax.all.*
import fs2.io.file.Files
import fs2.io.file.Path
import io.circe.Decoder
import io.circe.Encoder
import io.circe.Json
import io.circe.ParsingFailure
import io.circe.derivation.Configuration
import io.circe.yaml.Parser
import io.github.iltotore.iron.*
import io.github.iltotore.iron.circe.given
import pillars.AdminServer.Config
import pillars.PillarsError.Code
import pillars.PillarsError.ErrorNumber
import pillars.PillarsError.Message
import scala.util.matching.Regex
import scodec.bits.ByteVector

def config[F[_]](using p: Pillars[F]): Config.PillarsConfig = p.config

trait Config

object Config:
    case class PillarsConfig(
        name: App.Name,
        log: Logging.Config = Logging.Config(),
        api: ApiServer.Config,
        admin: AdminServer.Config,
        observability: Observability.Config
    ) extends pillars.Config

    object PillarsConfig:
        given Configuration          = Configuration.default.withKebabCaseMemberNames.withKebabCaseConstructorNames.withDefaults
        given Decoder[PillarsConfig] = Decoder.derivedConfigured
        given Encoder[PillarsConfig] = Encoder.AsObject.derivedConfigured
    end PillarsConfig

    case class Reader[F[_]](path: Path):
        private def matcher(regMatch: Regex.Match): String = sys.env
            .getOrElse(regMatch.group(1), throw ConfigError.MissingEnvironmentVariable(regMatch.group(1)))

        private val regex: Regex = """\$\{([^}]+)}""".r

        private def readConfig[T: Decoder](using Async[F], Files[F]): Resource[F, Either[ParsingFailure, Json]] =
            Resource.eval(Files[F].readUtf8(path)
                .map(regex.replaceAllIn(_, matcher))
                .map: input =>
                    Parser.default.parse(input)
                .compile
                .onlyOrError)

        def read[T: Decoder](using Async[F], Files[F]): F[T] =
            readConfig[T].use: json =>
                Sync[F].fromEither:
                    json
                        .leftMap(ConfigError.ParsingError.apply)
                        .flatMap(_.as[T])
        end read

        def read[T: Decoder](key: String)(using Async[F], Files[F]): F[T] =
            readConfig[T].use: parsed =>
                Sync[F].fromEither:
                    parsed match
                        case Left(failure) => Left(ConfigError.ParsingError(failure))
                        case Right(json)   => json.hcursor.downField(key).as[T].leftMap(ConfigError.ParsingError.apply)
    end Reader

    final case class Redacted[T](value: T) extends AnyVal:
        override def toString: String =
            s"REDACTED"

    object Redacted:
        given [T: Decoder: Show]: Decoder[Redacted[T]] = summon[Decoder[T]].map(Redacted.apply)

        given [T: Encoder: Show]: Encoder[Redacted[T]] = summon[Encoder[T]].contramap(_.value)
    end Redacted

    final case class Secret[T](value: T) extends AnyVal:
        override def toString: String =
            val hash = ByteVector(value.hashCode).padRight(4).toHex.take(4)
            s"REDACTED-$hash"
    end Secret

    object Secret:
        given [T: Decoder]: Decoder[Secret[T]] = summon[Decoder[T]].map(Secret.apply)

        given [T: Encoder]: Encoder[Secret[T]] = summon[Encoder[T]].contramap(_.value)
    end Secret

    private enum ConfigError(val number: ErrorNumber) extends PillarsError:
        override def code: Code = Code("CONF")

        case MissingEnvironmentVariable(name: String) extends ConfigError(ErrorNumber(1))
        case ParsingError(cause: Throwable)           extends ConfigError(ErrorNumber(2))

        override def message: Message = this match
            case ConfigError.MissingEnvironmentVariable(name) => Message(s"Missing environment variable $name".assume)
            case ConfigError.ParsingError(cause)              =>
                Message(s"Failed to parse configuration: ${cause.getMessage}".assume)
    end ConfigError
end Config




© 2015 - 2025 Weber Informatics LLC | Privacy Policy