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

unstatic.ztapir.ZTMain.scala Maven / Gradle / Ivy

package unstatic.ztapir

import scala.collection.*

import sttp.tapir.ztapir.*
import sttp.tapir.server.interceptor.log.DefaultServerLog
import sttp.tapir.server.ziohttp.{ZioHttpInterpreter, ZioHttpServerOptions}
import zio.http.{HttpApp, Request, Response}
import zio.http.Server
import zio.*

import unstatic.*, UrlPath.*

import java.nio.file.Path as JPath

object ZTMain:
  object Config:
    object List:
      val Default = List( false, None )
    case class List(allIdentifiers : Boolean, substringToMatch : Option[String])
    object Dynamic:
      enum IndexStyle:
        case RedirectToIndex, RedirectToSlash
      val DefaultPort = 8999
      val DefaultVerbose = false
      val DefaultDirectoryIndexes = immutable.Set("index.html","index.htm","index.rss","index.xml")
      val DefaultIndexStyle = IndexStyle.RedirectToIndex
      val Default = Dynamic(DefaultPort, DefaultVerbose, DefaultDirectoryIndexes, DefaultIndexStyle)
      given Config.Dynamic = Default
    case class Dynamic( port: Int, verbose: Boolean, directoryIndexes : immutable.Set[String], indexStyle : Dynamic.IndexStyle )
    object Static:
      val Default = Config.Static( JPath.of("public"), Nil )
      given Config.Static = Default
    case class Static( generateTo : JPath, noGenPrefixes : scala.Seq[Rooted] = Nil )
    enum Command:
      case gen, serve, hybrid, list
  case class Config(
    command    : Config.Command = Config.Command.gen,
    cfgList    : Config.List    = Config.List.Default,
    cfgStatic  : Config.Static  = Config.Static.Default,
    cfgDynamic : Config.Dynamic = Config.Dynamic.Default,
  )

  val VerboseServerInterpreterOptions: ZioHttpServerOptions[Any] =
  // modified from https://github.com/longliveenduro/zio-geolocation-tapir-tapir-starter/blob/b79c88b9b1c44a60d7c547d04ca22f12f420d21d/src/main/scala/com/tsystems/toil/Main.scala
    ZioHttpServerOptions
      .customiseInterceptors
      .serverLog(
        DefaultServerLog[Task](
          doLogWhenReceived = msg => ZIO.succeed(println(msg)),
          doLogWhenHandled = (msg, error) => ZIO.succeed(error.fold(println(msg))(err => println(s"msg: ${msg}, err: ${err}"))),
          doLogAllDecodeFailures = (msg, error) => ZIO.succeed(error.fold(println(msg))(err => println(s"msg: ${msg}, err: ${err}"))),
          doLogExceptions = (msg: String, exc: Throwable) => ZIO.succeed(println(s"msg: ${msg}, exc: ${exc}")),
          noLog = ZIO.unit
        )
      )
      .options
  val DefaltServerInterpreterOptions: ZioHttpServerOptions[Any] = ZioHttpServerOptions.default.widen[Any]
  def interpreterOptions( verbose : Boolean ) = if verbose then VerboseServerInterpreterOptions else DefaltServerInterpreterOptions

  def serve(site: ZTSite)(using cfg: Config.Dynamic) =
    def buildApp(endpointSource: ZTEndpointBinding.Source) =
      val endpointBindings = endpointSource.endpointBindings
      //val endpoints = endpointBindings.map(_.ztServerEndpoint)
      if (endpointBindings.isEmpty) throw new NoEndpointsDefined(s"No endpoints defined to serve from site for ${site.sitePath}.")

      // we need to find the directories associated with directory indexes, and create bindings for those
      val directoryIndexDirectoryBindingByIndexBinding =
        endpointBindings
          .collect { case g : ZTEndpointBinding.Generable => g }
          .map( g => Tuple2(g.siteRootedPath, g ) )
          .filter { case (path, _) =>
            val elements = path.elements
            elements.nonEmpty && cfg.directoryIndexes(elements.last)
          }
          .map { case (siteRootedDirIndexPath, fullIndexBinding) =>
            val serverRootedDirIndexPath = site.serverRootedPath(siteRootedDirIndexPath)

            val redirectBindings =
              cfg.indexStyle match
                case Config.Dynamic.IndexStyle.RedirectToIndex =>
                  Seq( redirectZTEndpointBinding( serverRootedDirIndexPath.parent, serverRootedDirIndexPath, site ) )
                case Config.Dynamic.IndexStyle.RedirectToSlash =>
                  val serverRootedDirIndexPathParent = serverRootedDirIndexPath.parent
                  val redirectEndpointBinding =
                    val asLeaf = serverRootedDirIndexPathParent.asLeaf
                    val ztServerEndpoint =
                      endpointForFixedPath( asLeaf )
                        .in( noTrailingSlash )
                        .out( redirectOutputs(serverRootedDirIndexPathParent) )
                        .zServerLogic( UnitUnitUnitLogic )
                        .glitchWiden
                    ZTEndpointBinding.generic( site.siteRootedPath(asLeaf), ztServerEndpoint, UnitThrowableUnitLogic, NoIdentifiers )
                  val slashEndpointBinding =
                    val basicEndpoint =
                      endpointForFixedPath(serverRootedDirIndexPathParent)
                        .errorOut(stringBody(CharsetUTF8))
                        .out(header(sttp.model.Header.contentType(fullIndexBinding.contentType)))
                    fullIndexBinding match
                      case sg : ZTEndpointBinding.StringGenerable =>
                        val coreLogic = (_:Unit) => sg.generator
                        val ztse =
                          val ct = fullIndexBinding.contentType
                          val htmlUtf8 = ct.mainType == "text" && ct.subType == "html" && sg.charset == CharsetUTF8
                          basicEndpoint
                            .out(if htmlUtf8 then htmlBodyUtf8 else stringBody(sg.charset))
                            .zServerLogic( errMapped(coreLogic) )
                            .glitchWiden
                        ZTEndpointBinding.generic(site.siteRootedPath(serverRootedDirIndexPathParent), ztse, coreLogic, NoIdentifiers)
                      case otherGenerable =>
                        val coreLogic = (_:Unit) => otherGenerable.bytesGenerator
                        val ztse =
                          basicEndpoint
                            .out( byteArrayBody )
                            .zServerLogic(errMapped(coreLogic.andThen( _.map(_.toArray) )))
                            .glitchWiden
                        ZTEndpointBinding.generic(site.siteRootedPath(serverRootedDirIndexPathParent), ztse, coreLogic, NoIdentifiers)
                  Seq( redirectEndpointBinding, slashEndpointBinding )
            ( fullIndexBinding, redirectBindings )
          }
          .toMap

      // then we need to place the directory bindings with the index bindings to preserve
      // the intended priority of resolution
      val enrichedEndpointBindings =
        endpointBindings.flatMap { origBinding =>
          origBinding match
            case gen : ZTEndpointBinding.Generable =>
              directoryIndexDirectoryBindingByIndexBinding.get(gen) match
                case Some( redirectBindings ) => redirectBindings :+ gen
                case None                     => Seq( gen )
            case _ => Seq( origBinding )
        }

      val enrichedEndpoints = enrichedEndpointBindings.map(_.ztServerEndpoint)

      if cfg.verbose then
        scala.Console.err.println("Endpoints to serve:")
        enrichedEndpoints.foreach( zse => scala.Console.err.println( "  - " + zse.show) )

      def toHttp(endpoint: ZTServerEndpoint) = ZioHttpInterpreter(interpreterOptions(cfg.verbose)).toHttp(endpoint)

      enrichedEndpoints.tail.foldLeft(toHttp(enrichedEndpoints.head))((accum, next) => accum ++ toHttp(next))

    val configLayer = ZLayer.succeed(Server.Config.default.port(cfg.port))
    val server =
      for
        app <- ZIO.attempt(buildApp(site))
        _   <- Console.printLineError(s"Beginning HTTP Service on port ${cfg.port}.")
        svr <- Server.serve(app)
      yield svr
    server.provide(configLayer, Server.live)

  def generate( site : ZTSite )(using cfg : Config.Static) =
    scribe.trace( s"generate( ${site} )")
    ZTStaticGen.generateZTSite( site, cfg.generateTo, cfg.noGenPrefixes )

  private def matchesSubstring( binding : AnyBinding, substr : String ) : Boolean =
    val srpLc = binding.siteRootedPath.toString().toLowerCase
    if srpLc.indexOf(substr) >= 0 then
      true
    else
      binding.identifiers.view.map( _.toLowerCase ).exists(str => str.indexOf(substr) >= 0)

  private def printIdentifierLine( id : String ) = Console.printLine("     \u27A3 " + id)
  private def printIdentifiers( binding : ZTEndpointBinding, site : ZTSite, cfg : Config.List ) : Task[Unit] =
    // binding identifiers are always sorted first by length (shortest first), then by String ordering
    val byLenUids = binding.identifiers.toVector.filter(id => !site.duplicateIdentifiers(id))
    if cfg.allIdentifiers then
      Console.printLine( "    identifiers (unique):" ) *> ZIO.foreachDiscard( byLenUids.map( printIdentifierLine ) )(identity)
    else
      byLenUids.headOption match
        case Some(identifier) => Console.printLine(s"    uid: ${identifier}")
        case None             => Console.printLine( "    uid: " )

  private def printInfoByType( site : ZTSite, binding : AnyBinding ) : Task[Unit] =
    binding match
      case slb : StaticLocationBinding =>
        Console.printLine("  Copy-on-gen filesystem location.") *> Console.printLine(s"    source-dir: ${slb.source}")
      case fsd : ZTEndpointBinding.FromStaticDirectory =>
        Console.printLine("  Static HTTP service endpoint.") *> Console.printLine(s"    source-dir: ${fsd.dir}")
      case sg : ZTEndpointBinding.StringGenerable =>
        Console.printLine(s"  String endpoint of type '${sg.contentType}'.") *>
        Console.printLine("  Static generation and HTTP service.") *>
          sg.mediaDirSiteRooted.fold(ZIO.unit) { mdsr =>
            Console.printLine(s"    media-dir: ${mdsr}") *>
            site.enforceUserContentFrom.fold(ZIO.unit) { seq =>
              val mdStr = if seq.size > 1 then "media-dirs" else "media-dir"
              seq.foldLeft {
                Console.printLine(s"    ${mdStr} (absolute): ")
              }{ (accum, next) =>
                val mediaDir = next.resolve(mdsr.unroot.toString())
                accum *> Console.printLine(s"      ${mediaDir.toAbsolutePath.toString}")
              }
            }
          }
      case bg : ZTEndpointBinding.BytesGenerable =>
        Console.printLine(s"  Binary endpoint of type '${bg.contentType}'.") *> Console.printLine("  Static generation and HTTP service.")
      case generic : ZTEndpointBinding.Generic[?,?] =>
        Console.printLine("  Generic endpoint. No further information.")
      case other =>
        Console.printLine(s"  Unexpected endpoint type: ${other}")

  def list( site : ZTSite )(using cfg : Config.List) : Task[Unit] =
    val bindings =
      cfg.substringToMatch match
        case Some(substr) => site.endpointBindings.filter(binding => matchesSubstring(binding, substr))
        case None         => site.endpointBindings
    val bindingsPrinters =
      bindings.map { binding =>
        for
          _ <- Console.printLine(s"Location: ${binding.siteRootedPath.toString()}")
          _ <- printInfoByType( site, binding)
          _ <- printIdentifiers( binding, site, cfg )
        yield ()
    }
    ZIO.foreachDiscard(bindingsPrinters)(identity)

abstract class ZTMain(site: ZTSite, executableName : String = "unstatic") extends ZIOAppDefault:
  import ZTMain.*
  def config( args : Array[String] ) : Config =
    import scopt.OParser
    val builder = OParser.builder[Config]
    val parser1 =
      import builder._
      OParser.sequence(
        programName(executableName),
        cmd(Config.Command.list.toString)
          .text("list and show information about endpoints")
          .action((_, cfg) => cfg.copy(command = Config.Command.list))
          .children(
             opt[Unit]('a', "all-identfiers")
              .action( (_, cfg) => cfg.copy( cfgList = cfg.cfgList.copy(allIdentifiers = true) ) )
              .text("Display all unique identifiers for each endpoint."),
             opt[String]('f', "filter-by-substring")
              .action( (x, cfg) => cfg.copy( cfgList = cfg.cfgList.copy(substringToMatch = Some(x) ) ) )
              .valueName("")
              .text("Restrict output to endpoints with path or identifiers containing substring."),
          ),
        cmd(Config.Command.gen.toString)
          .text("generate fully static site")
          .action((_, c) => c.copy(command = Config.Command.gen))
          .children(
             opt[java.io.File]('o', "out")
               .action((x, cfg) => cfg.copy( cfgStatic = cfg.cfgStatic.copy(generateTo = x.toPath) ) )
               .valueName("")
               .text("the output directory, into which the site will be generated"),
             opt[scala.Seq[String]]("no-gen-prefixes")
               .action((x, cfg) => cfg.copy( cfgStatic = cfg.cfgStatic.copy(noGenPrefixes = x.map(Rooted.parse)) ) )
               .text("prefixes for paths that should be ignored (skipped) for static generation")
               .valueName(",,..."),
          ),
        cmd(Config.Command.serve.toString)
          .text("serve site dynamically")
          .action((_, cfg) => cfg.copy(command = Config.Command.serve))
          .children(
            opt[Int]('p', "port")
              .action( (x, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(port = x) ) )
              .valueName("")
              .text("the port on which to serve HTTP"),
            opt[Unit]("verbose")
              .action( (_, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(verbose = true) ) )
              .text("emit verbose debugging output to stderr"),
             opt[scala.Seq[String]]("directory-indexes")
               .action( (x, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(directoryIndexes = x.toSet) ) )
               .text("names that can represent content of parent dir path")
               .valueName("index.html,index.htm,..."),
             opt[String]("index-redirect-to")
               .validate { x =>
                  if (x == "slash" || x == "index") then success
                  else failure("--index-redirect-to [slash|index] only" )
               }
               .action { (x, cfg) =>
                 val style =
                   if x == "slash" then
                     Config.Dynamic.IndexStyle.RedirectToSlash
                   else
                     Config.Dynamic.IndexStyle.RedirectToIndex
                 cfg.copy(cfgDynamic = cfg.cfgDynamic.copy(indexStyle = style))
               }
               .text("paths to directories with indexes should redirect to full index, or only to the directory trailing slash?")
               .valueName("[slash|index]"),
          ),
        cmd(Config.Command.hybrid.toString)
          .text("generate partial site and serve rest dynamically")
          .action((_, cfg) => cfg.copy(command = Config.Command.serve))
          .children(
             opt[java.io.File]('o', "out")
               .action( (x, cfg) => cfg.copy( cfgStatic = cfg.cfgStatic.copy(generateTo = x.toPath) ) )
               .valueName("")
               .text("the output directory, into which the site will be generated"),
             opt[scala.Seq[String]]("no-gen-prefixes")
               .action( (x, cfg) => cfg.copy( cfgStatic = cfg.cfgStatic.copy(noGenPrefixes = x.map(Rooted.parse)) ) )
               .text("prefixes for paths that should be ignored (skipped) for static generation")
               .valueName(",,..."),
             opt[scala.Seq[String]]("directory-indexes")
               .action( (x, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(directoryIndexes = x.toSet) ) )
               .text("names that can represent content of parent dir path")
               .valueName("index.html,index.htm,..."),
             opt[String]("index-redirect-to")
               .validate { x =>
                  if (x == "slash" || x == "index") then success
                  else failure("--index-redirect-to [slash|index] only" )
               }
               .action { (x, cfg) =>
                 val style =
                   if x == "slash" then
                     Config.Dynamic.IndexStyle.RedirectToSlash
                   else
                     Config.Dynamic.IndexStyle.RedirectToIndex
                 cfg.copy(cfgDynamic = cfg.cfgDynamic.copy(indexStyle = style))
               }
               .text("paths to directories with indexes should redirect to full index, or only to the directory trailing slash?")
               .valueName("[slash|index]"),
             opt[Int]('p', "port")
              .action( (x, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(port = x) ) )
              .valueName("")
              .text("the port on which to serve HTTP"),
            opt[Unit]("verbose")
              .action( (_, cfg) => cfg.copy( cfgDynamic = cfg.cfgDynamic.copy(verbose = true) ) )
              .text("emit verbose debugging output to stderr"),
          )
     )
    OParser.parse(parser1, args, Config()) match
      case Some(cfg) => cfg
      case _ => throw new BadCommandLine("Bad command line options provided: " + args.mkString(" "))

  def work( cfg : Config ) : Task[ZTStaticGen.Result] | Task[Unit] =
    scribe.trace( s"work( ${cfg} )")
    val genTask   = generate(site)(using cfg.cfgStatic)
    val serveTask = serve(site)(using cfg.cfgDynamic)
    val listTask  = list(site)(using cfg.cfgList)
    cfg.command match
      case Config.Command.list   => listTask
      case Config.Command.gen    => genTask
      case Config.Command.serve  => serveTask
      case Config.Command.hybrid =>
        for
          staticResult <- genTask
          _            <- serveTask
        yield staticResult

  def printEndpointsWithHeader(header : String, endpoints : immutable.Seq[Rooted]) : Task[Unit] =
    if endpoints.isEmpty then
      ZIO.unit
    else
      for
        _ <- Console.printLine(header)
        _ <- ZIO.foreach(endpoints.map(_.toString).to(immutable.SortedSet))( endpoint => Console.printLine(s" \u27A3 ${endpoint}"))
      yield ()

  def printCopiedWithHeader(header : String, staticEndpoints : immutable.Seq[Tuple2[Rooted,JPath]]) : Task[Unit] =
    val sortedEndpoints = staticEndpoints.sortBy( _(0).toString )
    if staticEndpoints.isEmpty then
      ZIO.unit
    else
      for
        _ <- Console.printLine(header)
        _ <- ZIO.foreach(sortedEndpoints)( endpoint => Console.printLine(s" \u27A3 ${endpoint(0)} <- ${endpoint(1)}"))
      yield ()

  /*
  // we were trying to debug a silent failure issue, hypothesized that
  // a fatal error was the issue. it was not, fatal errors are properly
  // logged, the issue is that failures represented by Cause are not
  // caught by tapError(...), so under some circumstances the app failed
  // silently and inscrutably.

  def logFatal( t : Throwable ) : Nothing =
    scribe.error("Fatal error!!!", t)
    throw t

  // modified from https://github.com/poslegm/munit-zio/blob/b6708da58ba1963166fff404c6209c80d3f3f775/core/src/main/scala/munit/ZRuntime.scala
  override def runtime: Runtime[Any] =
    Unsafe.unsafe { implicit unsafe =>
      Runtime.unsafe.fromLayer(Runtime.setReportFatal {
        case cause =>
          scribe.error("Fatal error!")
          cause.printStackTrace
          throw cause
      })
    }
  */


  def reportResult( result : ZTStaticGen.Result ) : Task[Unit] =
    val ZTStaticGen.Result( generated, copied, ignored, ungenerable ) = result
    for
      _ <- printEndpointsWithHeader("Endpoints generated:", generated)
      _ <- printCopiedWithHeader("Endpoints copied from static locations:", copied)
      _ <- printEndpointsWithHeader("Endpoints ignored by request:", ignored)
      _ <- printEndpointsWithHeader("Endpoints with ungenerable definitions skipped:", ungenerable)
    yield ()

  def reportMaybeResult( mbr : ZTStaticGen.Result | Unit ) : Task[Unit] =
    mbr match
      case result : ZTStaticGen.Result => reportResult(result)
      case _                           => ZIO.unit

  def reportFinalThrowable( t : Throwable ) = ZIO.attempt {
    t match
      case ur : UnresolvedReference =>
        val absInsert = ur.absolute.fold("")(abs =>
          s"${scala.util.Properties.lineSeparator}    Expected destination (absolute): ${abs}"
        )
        val msg =
          s"""|Unresolved Reference: ${ur.reference}
              |  ${ur.explanation}
              |    Source: ${ur.source}${absInsert}
              |""".stripMargin
        scala.Console.err.println(msg)
      case other =>
        other.printStackTrace()
  }

  override def run =
    runTask
      .tapDefect { cause =>
        scribe.error("Logging failures (but ugh, via ZIO.logCause(...)!!!")
        // XXX: If I'm logging with scribe, I need some way to decode the
        // cause into an exhaustive dump, rather than relying inconsistently
        // on ZIO.logCause.
        //
        // But for now I want to be sure that failures are never silent.
        // Didn't work:
        //   ZIO.attempt(cause.failures.foreach(f => scribe.error("Failure!", f)))
        ZIO.logCause(cause)
      }
      //.debug
      .catchSome { case _ : BadCommandLine => ZIO.unit }
      .tapError( reportFinalThrowable )
      .exitCode

  val runTask =
    for
      args <- getArgs
      cfg <- ZIO.attempt(config(args.toArray))
      mbr <- work(cfg)
      _   <- reportMaybeResult(mbr)
    yield ()
end ZTMain




© 2015 - 2025 Weber Informatics LLC | Privacy Policy