diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b81be3..cbaa370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Changed -- The API was upgraded to version 1.3. +- The API was upgraded to version 2.0. ## [0.4.4] - 2024-08-11 diff --git a/api.md b/api.md index f955a7b..542bf03 100644 --- a/api.md +++ b/api.md @@ -1,34 +1,38 @@ -# API - version 1.3 +# API - version 2.0 The API allows other programs to control *sc4pac* in a client-server fashion. In a nutshell: ``` -POST /init {plugins: "", cache: ""} +GET /profile.read?profile=id +POST /profile.init?profile=id {plugins: "", cache: ""} -GET /packages.list -GET /packages.info?pkg= -GET /packages.search?q= +GET /packages.list?profile=id +GET /packages.info?pkg=&profile=id +GET /packages.search?q=&profile=id -GET /plugins.added.list -GET /plugins.installed.list -POST /plugins.add ["", "", …] -POST /plugins.remove ["", "", …] +GET /plugins.added.list?profile=id +GET /plugins.installed.list?profile=id +POST /plugins.add?profile=id ["", "", …] +POST /plugins.remove?profile=id ["", "", …] -GET /variants.list -POST /variants.reset ["", "", …] +GET /variants.list?profile=id +POST /variants.reset?profile=id ["", "", …] -GET /update (websocket) +GET /update?profile=id (websocket) GET /server.status -GET /server.connect (websocket) +GET /server.connect (websocket) + +GET /profiles.list +POST /profiles.add {name: string} ``` - Everything JSON. - Package placeholders `` are of the form `:`. - Launch the server using the `sc4pac server` command. -- On the first time, invoke `/init` before anything else. +- On the first time, invoke `/profile.init` before anything else. - All endpoints may return some generic errors: - 400 (incorrect input) - 404 (non-existing packages, assets, etc.) @@ -44,12 +48,25 @@ GET /server.connect (websocket) } ``` -## init +## profile.read + +Read the config data stored for this profile. + +Synopsis: `GET /profile.read?profile=id` + +Returns: +- 200 `{pluginsRoot: string, cacheRoot: string, …}`. +- 409 `/error/profile-not-initialized` when not initialized. + The response contains + `platformDefaults: {plugins: ["", …], cache: ["", …]}` + for recommended platform-specific locations to use for initialization. + +## profile.init Initialize the profile by configuring the location of plugins and cache. Profiles are used to manage multiple plugins folders. -Synopsis: `POST /init {plugins: "", cache: ""}` +Synopsis: `POST /profile.init?profile=id {plugins: "", cache: ""}` Returns: - 409 `/error/init/not-allowed` if already initialized. @@ -60,13 +77,13 @@ Returns: ?> When managing multiple profiles, use the same cache for all of them. -- 200 `{"$type": "/result", "ok": true}` on success. +- 200 `{pluginsRoot: string, cacheRoot: string, …}`, same as `/profile.read`. **Examples:** Without parameters: ```sh -curl -X POST http://localhost:51515/init +curl -X POST http://localhost:51515/profile.init?profile= ``` Returns: ```json @@ -89,14 +106,14 @@ Returns: With parameters: ```sh -curl -X POST -d '{"plugins":"plugins","cache":"cache"}' http://localhost:51515/init +curl -X POST -d '{"plugins":"plugins","cache":"cache"}' http://localhost:51515/profile.init?profile= ``` ## packages.list Get the list of all installable packages by fetching all channels. -Synopsis: `GET /packages.list` +Synopsis: `GET /packages.list?profile=id` Returns: ``` @@ -107,7 +124,7 @@ Returns: Get detailed information about a single package. -Synopsis: `GET /packages.info?pkg=` +Synopsis: `GET /packages.info?pkg=&profile=id` Returns: [example](https://memo33.github.io/sc4pac/channel/metadata/memo/industrial-revolution-mod/latest/pkg.json ':include'). @@ -115,7 +132,7 @@ Returns: [example](https://memo33.github.io/sc4pac/channel/metadata/memo/industr Search for a package in all channels. -Synopsis: `GET /packages.search?q=` +Synopsis: `GET /packages.search?q=&profile=id` Optionally, bound the relevance by passing a `threshold` paramater ranging from 0 to 100. @@ -129,7 +146,7 @@ Returns: Get the list of packages that have been added explicitly (not necessarily installed yet). These packages are precisely the ones that can be removed. -Synopsis: `GET /plugins.added.list` +Synopsis: `GET /plugins.added.list?profile=id` Returns: `["", …]` @@ -137,7 +154,7 @@ Returns: `["", …]` Get the list of packages that are currently installed in your plugins. -Synopsis: `GET /plugins.installed.list` +Synopsis: `GET /plugins.installed.list?profile=id` Returns: ``` @@ -156,13 +173,13 @@ Returns: Add packages to the list of packages to install explicitly. -Synopsis: `POST /plugins.add ["", "", …]` +Synopsis: `POST /plugins.add?profile=id ["", "", …]` Returns: `{"$type": "/result", "ok": true}` Example: ```sh -curl -X POST -d '["cyclone-boom:save-warning"]' http://localhost:51515/plugins.add +curl -X POST -d '["cyclone-boom:save-warning"]' http://localhost:51515/plugins.add?profile= ``` ## plugins.remove @@ -170,7 +187,7 @@ curl -X POST -d '["cyclone-boom:save-warning"]' http://localhost:51515/plugins.a Remove packages from the list of packages to install explicitly. Only packages previously added can be removed. -Synopsis: `POST /plugins.remove ["", "", …]` +Synopsis: `POST /plugins.remove?profile=id ["", "", …]` Returns: - 400 `/error/bad-request` if one of the submitted packages is not in `/plugins.added.list` @@ -178,14 +195,14 @@ Returns: Example: ```sh -curl -X POST -d '["cyclone-boom:save-warning"]' http://localhost:51515/plugins.remove +curl -X POST -d '["cyclone-boom:save-warning"]' http://localhost:51515/plugins.remove?profile= ``` ## variants.list Get the list of configured variants of your plugins folder. -Synopsis: `GET /variants.list` +Synopsis: `GET /variants.list?profile=id` Returns: `{"": "", "": "", …}` @@ -193,7 +210,7 @@ Returns: `{"": "", "": "", …}` Reset selected variants by removing them from `/variants.list`. -Synopsis: `POST /variants.reset ["", "", …]` +Synopsis: `POST /variants.reset?profile=id ["", "", …]` Returns: - 400 `/error/bad-request` if one of the variant labels is not in `/variants.list` @@ -201,7 +218,7 @@ Returns: Example: ```sh -curl -X POST -d '["nightmode"]' http://localhost:51515/variants.reset +curl -X POST -d '["nightmode"]' http://localhost:51515/variants.reset?profile= ``` ## update @@ -212,7 +229,7 @@ The websocket sends a series of messages, some of which expect a specific respon Example using Javascript in your web browser: ```javascript -let ws = new WebSocket('ws://localhost:51515/update'); +let ws = new WebSocket('ws://localhost:51515/update?profile=id'); // ws.send(JSON.stringify({"$type": "/prompt/response", token: "", body: "Yes"})) ``` The messages sent from the server are logged in the network tab of the browser dev tools. @@ -315,3 +332,25 @@ Returns: `{"sc4pacVersion": "0.4.x"}` Monitor whether the server is still running by opening a websocket at this endpoint. No particular messages are exchanged, but if either client or server terminates, the other side will be informed about it as the websocket closes. + +## profiles.list + +Get the list of all existing profiles, each corresponding to a Plugins folder. + +Synopsis: `GET /profiles.list` + +Returns: +``` +{ + profiles: [{id: "", name: string}, …], + currentProfileId: [""] +} +``` + +## profiles.add + +Create a new profile and make it the currently active one. Make sure to call `/profile.init` afterwards. + +Synopsis: `POST /profiles.add {name: string}` + +Returns: `{"id": "", "name": string}` diff --git a/src/main/scala/sc4pac/Data.scala b/src/main/scala/sc4pac/Data.scala index a399f18..4a85b07 100644 --- a/src/main/scala/sc4pac/Data.scala +++ b/src/main/scala/sc4pac/Data.scala @@ -274,4 +274,34 @@ object JsonData extends SharedData { case class CheckFile(filename: Option[String], checksum: Checksum = Checksum.empty) derives ReadWriter + case class Profile(id: ProfileId, name: String) derives ReadWriter + + // or GuiConfig or GuiSettings + case class Profiles(profiles: Seq[Profile], currentProfileId: Option[ProfileId]) derives ReadWriter { + + private def nextId: ProfileId = { + val existing = profiles.map(_.id).toSet + Iterator.from(1).map(_.toString).dropWhile(existing).next + } + + def add(name: String): (Profiles, Profile) = { + val id = nextId + val profile = Profile(id = id, name = name) + (Profiles(profiles :+ profile, currentProfileId = Some(id)), profile) + } + } + object Profiles { + def path(profilesDir: os.Path): os.Path = profilesDir / "sc4pac-profiles.json" + + def pathURIO: URIO[ProfilesDir, os.Path] = ZIO.service[ProfilesDir].map(profilesDir => Profiles.path(profilesDir.path)) + + /** Read Profiles from file if it exists, else create it and write it to file. */ + val readOrInit: RIO[ProfilesDir, Profiles] = Profiles.pathURIO.flatMap { jsonPath => + ZIO.ifZIO(ZIO.attemptBlocking(os.exists(jsonPath)))( + onTrue = JsonIo.read[Profiles](jsonPath), + onFalse = ZIO.succeed(Profiles(Seq.empty, None)) + ) + } + } + } diff --git a/src/main/scala/sc4pac/api/api.scala b/src/main/scala/sc4pac/api/api.scala index 45db00e..5782ad2 100644 --- a/src/main/scala/sc4pac/api/api.scala +++ b/src/main/scala/sc4pac/api/api.scala @@ -4,7 +4,7 @@ package api import zio.http.* import zio.http.ChannelEvent.{Read, Unregistered, UserEvent, UserEventTriggered} -import zio.{ZIO, IO} +import zio.{ZIO, IO, URIO} import upickle.default as UP import sc4pac.JsonData as JD @@ -17,9 +17,19 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { private def jsonResponse[A : UP.Writer](obj: A): Response = Response.json(UP.write(obj, indent = options.indent)) private def jsonFrame[A : UP.Writer](obj: A): WebSocketFrame = WebSocketFrame.Text(UP.write(obj, indent = options.indent)) + private val makePlatformDefaults: URIO[ProfileRoot, Map[String, Seq[String]]] = + for { + defPlugins <- JD.Plugins.defaultPluginsRoot + defCache <- JD.Plugins.defaultCacheRoot + } yield Map("plugins" -> defPlugins.map(_.toString), "cache" -> defCache.map(_.toString)) + /** Sends a 409 ProfileNotInitialized if Plugins cannot be loaded. */ private val readPluginsOr409: ZIO[ProfileRoot, Response, JD.Plugins] = - JD.Plugins.read.mapError((err: ErrStr) => jsonResponse(ErrorMessage.ProfileNotInitialized("Profile not initialized", err)).status(Status.Conflict)) + JD.Plugins.read.flatMapError { (err: ErrStr) => + for { + defaults <- makePlatformDefaults + } yield jsonResponse(ErrorMessage.ProfileNotInitialized("Profile not initialized", err, platformDefaults = defaults)).status(Status.Conflict) + } private def expectedFailureMessage(err: cli.Commands.ExpectedFailure): ErrorMessage = err match { case abort: error.Sc4pacVersionNotFound => ErrorMessage.VersionNotFound(abort.title, abort.detail) @@ -51,7 +61,7 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { } /** Handles some errors and provides http logger (not used for update-websocket). */ - private def wrapHttpEndpoint(task: ZIO[ProfileRoot & Logger, Throwable | Response, Response]): ZIO[ProfileRoot, Throwable, Response] = { + private def wrapHttpEndpoint[R](task: ZIO[R & Logger, Throwable | Response, Response]): ZIO[R, Throwable, Response] = { task.provideSomeLayer(httpLogger) .catchAll { case response: Response => ZIO.succeed(response) @@ -81,10 +91,20 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { .mapError(failedLabels => jsonResponse(errMsg(failedLabels)).status(Status.BadRequest)) } - def routes: Routes[ProfileRoot, Nothing] = Routes( + /** Routes that require a `profile=id` query parameter as part of the URL. */ + def profileRoutes: Routes[ProfileRoot, Throwable] = Routes( + + // 200, 409 + Method.GET / "profile.read" -> handler { (req: Request) => + wrapHttpEndpoint { + for { + pluginsData <- readPluginsOr409 + } yield jsonResponse(pluginsData.config) + } + }, // 200, 400, 409 - Method.POST / "init" -> handler { (req: Request) => + Method.POST / "profile.init" -> handler { (req: Request) => wrapHttpEndpoint { for { profileRoot <- ZIO.serviceWith[ProfileRoot](_.path) @@ -93,21 +113,21 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { ErrorMessage.InitNotAllowed("Profile already initialized.", "Manually delete the corresponding .json files if you are sure you want to initialize a new profile.") ).status(Status.Conflict)) - defPlugins <- JD.Plugins.defaultPluginsRoot - defCache <- JD.Plugins.defaultCacheRoot + defaults <- makePlatformDefaults initArgs <- parseOr400[InitArgs](req.body, ErrorMessage.BadInit( """Parameters "plugins" and "cache" are required.""", "Pass the locations of the folders as JSON dictionary: {plugins: , cache: }.", - platformDefaults = Map("plugins" -> defPlugins.map(_.toString), "cache" -> defCache.map(_.toString)) + platformDefaults = defaults, )) pluginsRoot = os.Path(initArgs.plugins, profileRoot) cacheRoot = os.Path(initArgs.cache, profileRoot) _ <- ZIO.attemptBlockingIO { + os.makeDir.all(profileRoot) os.makeDir.all(pluginsRoot) // TODO ask for confirmation? os.makeDir.all(cacheRoot) } pluginsData <- JD.Plugins.init(pluginsRoot = pluginsRoot, cacheRoot = cacheRoot) - } yield jsonOk + } yield jsonResponse(pluginsData.config) } }, @@ -286,34 +306,78 @@ class Api(options: sc4pac.cli.Commands.ServerOptions) { } }, - // 200 - Method.GET / "server.status" -> handler { - wrapHttpEndpoint { - ZIO.succeed(jsonResponse(ServerStatus(sc4pacVersion = cli.BuildInfo.version))) - } - }, + ) + + def routes: Routes[ProfilesDir, Nothing] = { + // Extract profile ID from URL query parameter and add it to environment. + // 400 error if "profile" parameter is absent. + val profileRoutes2 = + profileRoutes.transform((handler0) => handler { (req: Request) => + req.url.queryParams.get("profile") match { + case Some[ProfileId](id) => + handler0(req).provideSomeLayer(zio.ZLayer.fromFunction((dir: ProfilesDir) => ProfileRoot(dir.path / id))) + case None => + ZIO.fail(jsonResponse(ErrorMessage.BadRequest( + """URL query parameter "profile" is required.""", "Pass the profile ID as query." + )).status(Status.BadRequest)) + } + }) - // websocket allowing to monitor whether server is alive (supports no particular message exchange) - Method.GET / "server.connect" -> handler { - val num = connectionCount.incrementAndGet() - Handler.webSocket { wsChannel => - for { - logger <- ZIO.service[Logger] - _ <- ZIO.succeed(logger.log(s"Registered websocket connection $num.")) - _ <- wsChannel.receiveAll { - case UserEventTriggered(UserEvent.HandshakeComplete) => ZIO.succeed(()) // ignore expected event - case Unregistered => - logger.log(s"Unregistered websocket connection $num.") // client closed websocket (results in websocket shutdown) - ZIO.succeed(()) - case event => - logger.warn(s"Discarding unexpected websocket event: $event") - ZIO.succeed(()) // discard all unexpected messages (and events) and continue receiving - } - _ <- wsChannel.shutdown: zio.UIO[Unit] // may be redundant - } yield logger.log(s"Shut down websocket connection $num.") - }.provideSomeLayer(httpLogger).toResponse: zio.URIO[ProfileRoot, Response] - }, + // profile-independent routes + val genericRoutes = Routes[ProfilesDir, Throwable]( - ).handleError(err => jsonResponse(ErrorMessage.ServerError("Unhandled error.", err.getMessage)).status(Status.InternalServerError)) + // 200 + Method.GET / "server.status" -> handler { + wrapHttpEndpoint { + ZIO.succeed(jsonResponse(ServerStatus(sc4pacVersion = cli.BuildInfo.version))) + } + }, + + // websocket allowing to monitor whether server is alive (supports no particular message exchange) + Method.GET / "server.connect" -> handler { + val num = connectionCount.incrementAndGet() + Handler.webSocket { wsChannel => + for { + logger <- ZIO.service[Logger] + _ <- ZIO.succeed(logger.log(s"Registered websocket connection $num.")) + _ <- wsChannel.receiveAll { + case UserEventTriggered(UserEvent.HandshakeComplete) => ZIO.succeed(()) // ignore expected event + case Unregistered => + logger.log(s"Unregistered websocket connection $num.") // client closed websocket (results in websocket shutdown) + ZIO.succeed(()) + case event => + logger.warn(s"Discarding unexpected websocket event: $event") + ZIO.succeed(()) // discard all unexpected messages (and events) and continue receiving + } + _ <- wsChannel.shutdown: zio.UIO[Unit] // may be redundant + } yield logger.log(s"Shut down websocket connection $num.") + }.provideSomeLayer(httpLogger).toResponse: zio.URIO[ProfilesDir, Response] + }, + + // 200 + Method.GET / "profiles.list" -> handler { + wrapHttpEndpoint { + JD.Profiles.readOrInit.map(jsonResponse) + } + }, + + // 200, 400 + Method.POST / "profiles.add" -> handler { (req: Request) => + wrapHttpEndpoint { + for { + profileName <- parseOr400[ProfileName](req.body, ErrorMessage.BadRequest("Missing profile name.", "Pass the \"name\" of the new profile.")) + ps <- JD.Profiles.readOrInit + (ps2, p) = ps.add(profileName.name) + jsonPath <- JD.Profiles.pathURIO + _ <- ZIO.attemptBlockingIO { os.makeDir.all(jsonPath / os.up) } + _ <- JsonIo.write(jsonPath, ps2, None)(ZIO.succeed(())) + } yield jsonResponse(p) + } + }, + ) + + (profileRoutes2 ++ genericRoutes) + .handleError(err => jsonResponse(ErrorMessage.ServerError("Unhandled error.", err.toString)).status(Status.InternalServerError)) + } } diff --git a/src/main/scala/sc4pac/api/message.scala b/src/main/scala/sc4pac/api/message.scala index 544358c..98bd2ef 100644 --- a/src/main/scala/sc4pac/api/message.scala +++ b/src/main/scala/sc4pac/api/message.scala @@ -85,7 +85,7 @@ object ErrorMessage { @upickle.implicits.key("/error/server-error") case class ServerError(title: String, detail: String) extends ErrorMessage derives UP.ReadWriter @upickle.implicits.key("/error/profile-not-initialized") - case class ProfileNotInitialized(title: String, detail: String) extends ErrorMessage derives UP.ReadWriter + case class ProfileNotInitialized(title: String, detail: String, platformDefaults: Map[String, Seq[String]]) extends ErrorMessage derives UP.ReadWriter @upickle.implicits.key("/error/version-not-found") case class VersionNotFound(title: String, detail: String) extends ErrorMessage derives UP.ReadWriter @upickle.implicits.key("/error/asset-not-found") @@ -144,3 +144,5 @@ case class ChannelContentsItem(`package`: BareModule, version: String, summary: case class InitArgs(plugins: String, cache: String) derives UP.ReadWriter case class ServerStatus(sc4pacVersion: String) derives UP.ReadWriter + +case class ProfileName(name: String) derives UP.ReadWriter diff --git a/src/main/scala/sc4pac/cli.scala b/src/main/scala/sc4pac/cli.scala index afc9600..6713eed 100644 --- a/src/main/scala/sc4pac/cli.scala +++ b/src/main/scala/sc4pac/cli.scala @@ -468,7 +468,7 @@ object Commands { |Start a local server to use the HTTP API. | |Example: - | sc4pac server --indent 2 --profile-root profiles/profile-1/ + | sc4pac server --indent 1 --profiles-dir profiles """.stripMargin.trim) final case class ServerOptions( @ValueDescription("number") @Group("Server") @Tag("Server") @@ -478,33 +478,24 @@ object Commands { @HelpMessage(s"indentation of JSON responses (default: -1, no indentation)") indent: Int = -1, @ValueDescription("path") @Group("Server") @Tag("Server") - @HelpMessage(s"root directory containing sc4pac-plugins.json (default: current working directory), newly created if necessary; " - + "can be used for managing multiple different plugins folders") - profileRoot: String = "", - @ValueDescription("path") @Group("Server") @Tag("Server") - @HelpMessage("deprecated (use --profile-root instead)") - scopeRoot: String = "", + @HelpMessage(s"directory containing the sc4pac-profiles.json file and profile sub-directories (default: current working directory), newly created if necessary") + profilesDir: String = "", ) extends Sc4pacCommandOptions case object Server extends Command[ServerOptions] { def run(options: ServerOptions, args: RemainingArgs): Unit = { if (options.indent < -1) error(caseapp.core.Error.Other(s"Indentation must be -1 or larger.")) - val profileRoot: os.Path = { - val optProfileRoot = if (options.scopeRoot.nonEmpty) { - println("Option --scope-root is deprecated. Use --profile-root instead.") - options.scopeRoot - } else options.profileRoot - if (optProfileRoot.isEmpty) os.pwd else os.Path(java.nio.file.Paths.get(optProfileRoot), os.pwd) - } - if (!os.exists(profileRoot)) { - println(s"Creating sc4pac profile directory: $profileRoot") - os.makeDir.all(profileRoot) + val profilesDir: os.Path = + if (options.profilesDir.isEmpty) os.pwd else os.Path(java.nio.file.Paths.get(options.profilesDir), os.pwd) + if (!os.exists(profilesDir)) { + println(s"Creating sc4pac profiles directory: $profilesDir") + os.makeDir.all(profilesDir) } val task: Task[Unit] = { val app = sc4pac.api.Api(options).routes.toHttpApp println(s"Starting sc4pac server on port ${options.port}...") - zio.http.Server.serve(app).provide(zio.http.Server.defaultWithPort(options.port), zio.ZLayer.succeed(ProfileRoot(profileRoot))) + zio.http.Server.serve(app).provide(zio.http.Server.defaultWithPort(options.port), zio.ZLayer.succeed(ProfilesDir(profilesDir))) } runMainExit(task, exit) } diff --git a/src/main/scala/sc4pac/package.scala b/src/main/scala/sc4pac/package.scala index df80e45..bb0b720 100644 --- a/src/main/scala/sc4pac/package.scala +++ b/src/main/scala/sc4pac/package.scala @@ -3,6 +3,7 @@ package object sc4pac { type ErrStr = String type Warning = String type Variant = Map[String, String] + type ProfileId = String object CoursierZio { // from https://github.com/kitlangton/scala-update/blob/2249cd613c6490142201a0cfd9cbcf7b2ecbda36/src/main/scala/update/versions/ZioSyncInstance.scala @@ -65,7 +66,10 @@ package object sc4pac { zio.Runtime.default.unsafe.run(effect).getOrThrowFiberFailure() } + /** Root directory of an individual profile. */ class ProfileRoot(val path: os.Path) + /** Directory containing all the profiles. */ + class ProfilesDir(val path: os.Path) case class Artifact( url: String,