From d7afe65eafe2989c2a6753e270aeba473656ca0a Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 11:58:06 +0300 Subject: [PATCH 01/31] fix(client): honour CodebergConfig.defaultPageSize as the first-page default CodebergConfig.defaultPageSize was documented and tested but never read by production code: every listing takes explicit PageParams, and the only ready-made starting window, PageParams.First, hardcodes PageSize.Default. A caller who configured a different size on a self-hosted instance got the library default anyway unless they built their own PageParams by hand. CodebergClient now exposes val firstPage, page one at the configured defaultPageSize, which is where listings and PageWalk should start. PageParams.First stays as the config-free constant for code with no client in hand, since the domain module cannot see any client's configuration. README, the pagination examples, the PageWalk Scaladoc and the self-hosted guide now start from client.firstPage, and CodebergClientSuite pins the wiring. --- README.md | 19 +++++++++++++------ .../worxbend/codeberg4s/CodebergClient.scala | 15 ++++++++++++++- .../worxbend/codeberg4s/paging/PageWalk.scala | 4 ++-- .../codeberg4s/CodebergClientSuite.scala | 11 +++++++++++ .../codeberg4s/paging/PageParams.scala | 7 ++++++- site/src/guides/09-self-hosted.md | 4 ++++ 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 18b9c8c..8bdaccd 100644 --- a/README.md +++ b/README.md @@ -459,16 +459,18 @@ unbounded collection by accident. One page at a time: import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.Page -import com.worxbend.codeberg4s.paging.PageParams import scala.concurrent.Future val first: Future[Page[Issue]] = - client.issues.list(owner, name, IssueQuery.Empty, PageParams.First) + client.issues.list(owner, name, IssueQuery.Empty, client.firstPage) ``` -A `Page[A]` carries `items`, the `params` that produced it, an optional -`totalCount` from the `x-total-count` header, and `nextPage` / `prevPage`. +`client.firstPage` is page 1 at the client's configured `defaultPageSize`; +`PageParams.First` is the same window at the library-wide default size, for +code that has no client in hand. A `Page[A]` carries `items`, the `params` +that produced it, an optional `totalCount` from the `x-total-count` header, +and `nextPage` / `prevPage`. ### The clamp hazard — why `items.size` is the wrong end-of-pages test @@ -520,12 +522,14 @@ Two more traps worth naming: ignore it and return the entire collection — 862 forks, 5233 stargazers in the captured fixtures. -Or let `PageWalk` drive the loop, on any listing in the library: +Or let `PageWalk` drive the loop, on any listing in the library. Start it from +`client.firstPage` — page 1 at the client's configured `defaultPageSize` — +rather than the config-free constant `PageParams.First`: ```scala import com.worxbend.codeberg4s.paging.PageWalk -PageWalk.all(PageParams.First): params => +PageWalk.all(client.firstPage): params => client.issues.list(owner, name, IssueQuery.Empty, params) ``` @@ -573,6 +577,9 @@ val selfHosted: Either[ValidationError, CodebergConfig] = ) ``` +`defaultPageSize` surfaces on the built client as `client.firstPage` — page 1 +at that size — which is what listings and `PageWalk` should start from. + Every field naming a domain concept is a validated type, so a misconfigured client fails at construction rather than on its first call. The timeouts and the two byte bounds are plain quantities and are taken as given. diff --git a/modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala b/modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala index ccc872e..fe524a1 100644 --- a/modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala +++ b/modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala @@ -12,6 +12,8 @@ import com.worxbend.codeberg4s.issues.IssueApi import com.worxbend.codeberg4s.miscellaneous.MiscellaneousApi import com.worxbend.codeberg4s.notifications.NotificationApi import com.worxbend.codeberg4s.organizations.OrganizationApi +import com.worxbend.codeberg4s.paging.PageNumber +import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.pulls.PullRequestApi import com.worxbend.codeberg4s.repositories.RepositoryApi import com.worxbend.codeberg4s.repositories.actions.ActionDownloadApi @@ -55,12 +57,23 @@ import java.util.concurrent.atomic.AtomicBoolean * [[CodebergClient.apply]] and [[CodebergClient.usingBackend]]. */ final class CodebergClient private ( + config: CodebergConfig, pipeline: ApiPipeline[Future], binary: BinaryHttpPort[Future], timer: FutureTimer, ownedBackend: Option[Backend[Future]], )(using Exec[Future]): + /** The first page at this client's [[CodebergConfig.defaultPageSize]] — where a listing or a + * [[com.worxbend.codeberg4s.paging.PageWalk]] against this client starts. + * + * This is how `defaultPageSize` reaches the listings: every listing takes explicit + * [[com.worxbend.codeberg4s.paging.PageParams]], and this value is those params pre-filled from the configuration. + * [[com.worxbend.codeberg4s.paging.PageParams.First]] is the config-free constant with the library-wide default + * size; prefer `client.firstPage` when the client is in hand so a configured size is honoured. + */ + val firstPage: PageParams = PageParams(PageNumber.First, config.defaultPageSize) + /** `GET /version` — what software the instance is running. */ val version: VersionApi = VersionApi(pipeline) @@ -226,4 +239,4 @@ object CodebergClient: ApiErrorBodyCodec.parse, ) - new CodebergClient(pipeline, port, timer, owned) + new CodebergClient(config, pipeline, port, timer, owned) diff --git a/modules/client/src/com/worxbend/codeberg4s/paging/PageWalk.scala b/modules/client/src/com/worxbend/codeberg4s/paging/PageWalk.scala index 8afa639..5adab78 100644 --- a/modules/client/src/com/worxbend/codeberg4s/paging/PageWalk.scala +++ b/modules/client/src/com/worxbend/codeberg4s/paging/PageWalk.scala @@ -15,7 +15,7 @@ import scala.concurrent.Future * * {{{ * val all: Future[Vector[Issue]] = - * PageWalk.all(PageParams.First): params => + * PageWalk.all(client.firstPage): params => * client.issues.list(owner, name, IssueQuery.Empty, params) * }}} * @@ -62,7 +62,7 @@ object PageWalk: * Convenient and bounded only by the data: read the memory note above before using it on a large repository. * * @param first - * the page to start from, usually [[PageParams.First]] + * the page to start from, usually `client.firstPage` (or [[PageParams.First]] when no client is in hand) * @param fetch * the listing operation, applied once per page */ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala index a4b44a9..d2de7f2 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala @@ -2,6 +2,9 @@ package com.worxbend.codeberg4s import com.worxbend.codeberg4s.auth.ApiToken import com.worxbend.codeberg4s.auth.Auth +import com.worxbend.codeberg4s.paging.PageNumber +import com.worxbend.codeberg4s.paging.PageParams +import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.Owner import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository @@ -161,6 +164,14 @@ final class CodebergClientSuite extends FunSuite: assertEquals(backend.closes, 0) + test("firstPage is page one at the configured default page size"): + val size = orFail(PageSize.from(7)) + val config = configFor(Auth.Anonymous).copy(defaultPageSize = size) + val client = CodebergClient.usingBackend(config, responding(200, CodebergClientSuite.VersionBody)) + + try assertEquals(client.firstPage, PageParams(PageNumber.First, size)) + finally client.close() + test("a configured token appears in nothing the caller can see about a failure"): val config = configFor(Auth.Token(orFail(ApiToken.from(CodebergClientSuite.Secret)))) diff --git a/modules/domain/src/com/worxbend/codeberg4s/paging/PageParams.scala b/modules/domain/src/com/worxbend/codeberg4s/paging/PageParams.scala index 116b540..09fd95d 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/paging/PageParams.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/paging/PageParams.scala @@ -20,5 +20,10 @@ final case class PageParams(page: PageNumber, size: PageSize): object PageParams: - /** The first page at [[PageSize.Default]] — where a fold over all pages starts. */ + /** The first page at [[PageSize.Default]] — where a fold over all pages starts. + * + * This constant is deliberately config-free: the domain module knows nothing about any particular client. A + * `CodebergClient` exposes `firstPage`, the same window at the client's configured `defaultPageSize` — prefer that + * when a client is in hand. + */ val First: PageParams = PageParams(PageNumber.First, PageSize.Default) diff --git a/site/src/guides/09-self-hosted.md b/site/src/guides/09-self-hosted.md index cee6d84..ce8243f 100644 --- a/site/src/guides/09-self-hosted.md +++ b/site/src/guides/09-self-hosted.md @@ -39,6 +39,10 @@ val selfHosted: Either[ValidationError, CodebergConfig] = ) ``` +`defaultPageSize` surfaces on the built client as `client.firstPage` — page 1 +at that size — which is what listings and `PageWalk` should start from on an +instance whose page ceiling differs from codeberg.org's. + Or, if only the host differs: ```scala mdoc:compile-only From e78938e46f20363715791b1216267522fac90c35 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:01:18 +0300 Subject: [PATCH 02/31] test(client): enforce Api/Attempt rail parity by reflection ADR-0005 promises that the convenience rail and its Attempt mirror cannot disagree, but the 38 nested Attempt classes are written by hand and nothing checked the promise. Each mirror method is one more place a rename, an added parameter, or a new operation can silently miss. Add AttemptParitySuite, which holds an explicit registry of every (Api, Api.Attempt) pair and, via java.lang.reflect, asserts that every public Future-returning rail method has a mirror with the same name and erased parameter list, that the mirror declares nothing extra, and that the mirror's generic return type is exactly the rail's result wrapped in Either[CodebergError, _]. The registry itself is kept honest by walking the API-typed accessors reachable from CodebergClient, so a new group cannot land without joining the check. Runs in the normal client test suite; the mirrors stay hand-written per ADR-0005. --- .../codeberg4s/AttemptParitySuite.scala | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala diff --git a/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala new file mode 100644 index 0000000..c392cf4 --- /dev/null +++ b/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala @@ -0,0 +1,207 @@ +package com.worxbend.codeberg4s + +import munit.FunSuite + +import scala.concurrent.Future + +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +/** Enforces ADR-0005 mechanically: the convenience rail and its hand-written `Attempt` mirror cannot disagree. + * + * Every API group carries two rails — the methods on the group itself, failing the `Future`, and the same operations + * on the nested `Attempt`, returning `Either[CodebergError, A]`. The mirrors are written by hand, so nothing in the + * type system keeps them in step; this suite closes that gap by reflection. For every registered pair it checks that + * + * - every public rail operation (a public method returning `Future`) has an `Attempt` method with the same name and + * the same erased parameter list, + * - the `Attempt` declares no operation the rail lacks, + * - the `Attempt` method's return type is exactly the rail's with `Either[CodebergError, _]` spliced inside the + * `Future` (compared on generic signatures, because erasure hides the type arguments), + * - and the registry itself is complete: walking the API-typed accessors reachable from [[CodebergClient]] finds + * exactly the rail classes listed here, so a new API group cannot be added without joining the check. + */ +final class AttemptParitySuite extends FunSuite: + + import AttemptParitySuite.* + + test("every rail declares at least one operation, so the parity checks cannot pass vacuously"): + for pair <- Pairs do + assert( + operationKeys(pair.rail).nonEmpty, + s"${pair.rail.getName}: no public Future-returning methods found — the operation filter is broken", + ) + + test("every rail operation has an Attempt mirror with the same name and erased parameters"): + for pair <- Pairs do + val rail = operationKeys(pair.rail) + val mirror = operationKeys(pair.attempt) + val missing = rail -- mirror + assert( + missing.isEmpty, + s"${pair.rail.getName}: operations without an Attempt mirror: ${render(missing)}", + ) + + test("the Attempt declares no operation the rail lacks"): + for pair <- Pairs do + val rail = operationKeys(pair.rail) + val mirror = operationKeys(pair.attempt) + val extra = mirror -- rail + assert( + extra.isEmpty, + s"${pair.attempt.getName}: Attempt methods with no rail counterpart: ${render(extra)}", + ) + + test("each Attempt mirror returns the rail's result wrapped in Either[CodebergError, _]"): + for pair <- Pairs do + val rails = operations(pair.rail) + val mirrors = operations(pair.attempt) + for (key, railMethod) <- rails do + mirrors.get(key).foreach: mirrorMethod => + val expected = s"scala.concurrent.Future>" + assertEquals( + mirrorMethod.getGenericReturnType.getTypeName, + expected, + s"${pair.attempt.getName}.${key._1}: Attempt return type disagrees with the rail", + ) + + test("the pair registry covers exactly the API classes reachable from CodebergClient"): + val registered = Pairs.map(_.rail).toSet + val reachable = reachableApis + assertEquals( + (reachable -- registered).map(_.getName).toList.sorted, + List.empty[String], + "API classes reachable from CodebergClient but missing from this suite's registry", + ) + assertEquals( + (registered -- reachable).map(_.getName).toList.sorted, + List.empty[String], + "registry entries no CodebergClient accessor reaches", + ) + assertEquals(Pairs.map(_.rail).distinct.size, Pairs.size, "duplicate rail classes in the registry") + + test("each registered Attempt is the nested Attempt of its own rail's companion"): + for pair <- Pairs do + assertEquals( + pair.attempt.getName, + s"${pair.rail.getName}$$Attempt", + s"${pair.attempt.getName} is not the Attempt nested in ${pair.rail.getName}'s companion", + ) + +object AttemptParitySuite: + + /** One rail class and its hand-written mirror. */ + private final case class Pair(rail: Class[?], attempt: Class[?]) + + /** Every API class with a nested `Attempt`, paired with that `Attempt`. Kept complete by the coverage test. */ + private val Pairs: Seq[Pair] = Seq( + Pair(classOf[VersionApi], classOf[VersionApi.Attempt]), + Pair(classOf[issues.IssueApi], classOf[issues.IssueApi.Attempt]), + Pair(classOf[issues.IssueCommentApi], classOf[issues.IssueCommentApi.Attempt]), + Pair(classOf[issues.IssueAttachmentApi], classOf[issues.IssueAttachmentApi.Attempt]), + Pair(classOf[issues.IssueReactionApi], classOf[issues.IssueReactionApi.Attempt]), + Pair(classOf[issues.IssueLabelApi], classOf[issues.IssueLabelApi.Attempt]), + Pair(classOf[issues.IssueMilestoneApi], classOf[issues.IssueMilestoneApi.Attempt]), + Pair(classOf[issues.IssueTimeApi], classOf[issues.IssueTimeApi.Attempt]), + Pair(classOf[issues.IssueSubscriptionApi], classOf[issues.IssueSubscriptionApi.Attempt]), + Pair(classOf[pulls.PullRequestApi], classOf[pulls.PullRequestApi.Attempt]), + Pair(classOf[miscellaneous.MiscellaneousApi], classOf[miscellaneous.MiscellaneousApi.Attempt]), + Pair(classOf[notifications.NotificationApi], classOf[notifications.NotificationApi.Attempt]), + Pair(classOf[organizations.OrganizationApi], classOf[organizations.OrganizationApi.Attempt]), + Pair(classOf[organizations.OrganizationHookApi], classOf[organizations.OrganizationHookApi.Attempt]), + Pair(classOf[organizations.OrganizationLabelApi], classOf[organizations.OrganizationLabelApi.Attempt]), + Pair(classOf[organizations.OrganizationQuotaApi], classOf[organizations.OrganizationQuotaApi.Attempt]), + Pair(classOf[organizations.OrganizationTeamApi], classOf[organizations.OrganizationTeamApi.Attempt]), + Pair( + classOf[organizations.actions.OrganizationActionApi], + classOf[organizations.actions.OrganizationActionApi.Attempt], + ), + Pair(classOf[repositories.RepositoryApi], classOf[repositories.RepositoryApi.Attempt]), + Pair(classOf[repositories.access.RepositoryAccessApi], classOf[repositories.access.RepositoryAccessApi.Attempt]), + Pair(classOf[repositories.actions.ActionDownloadApi], classOf[repositories.actions.ActionDownloadApi.Attempt]), + Pair( + classOf[repositories.actions.RepositoryActionApi], + classOf[repositories.actions.RepositoryActionApi.Attempt], + ), + Pair(classOf[repositories.admin.RepositoryAdminApi], classOf[repositories.admin.RepositoryAdminApi.Attempt]), + Pair(classOf[repositories.gitdata.RepositoryGitApi], classOf[repositories.gitdata.RepositoryGitApi.Attempt]), + Pair(classOf[repositories.hooks.RepositoryFlagApi], classOf[repositories.hooks.RepositoryFlagApi.Attempt]), + Pair(classOf[repositories.hooks.RepositoryHookApi], classOf[repositories.hooks.RepositoryHookApi.Attempt]), + Pair( + classOf[repositories.hooks.RepositoryIssueConfigApi], + classOf[repositories.hooks.RepositoryIssueConfigApi.Attempt], + ), + Pair(classOf[repositories.hooks.RepositoryWikiApi], classOf[repositories.hooks.RepositoryWikiApi.Attempt]), + Pair( + classOf[repositories.publishing.RepositoryPublishingApi], + classOf[repositories.publishing.RepositoryPublishingApi.Attempt], + ), + Pair(classOf[users.UserApi], classOf[users.UserApi.Attempt]), + Pair(classOf[users.account.UserAccountApi], classOf[users.account.UserAccountApi.Attempt]), + Pair(classOf[users.account.UserActionApi], classOf[users.account.UserActionApi.Attempt]), + Pair(classOf[users.account.UserApplicationApi], classOf[users.account.UserApplicationApi.Attempt]), + Pair(classOf[users.account.UserHookApi], classOf[users.account.UserHookApi.Attempt]), + Pair(classOf[users.account.UserQuotaApi], classOf[users.account.UserQuotaApi.Attempt]), + Pair(classOf[users.social.UserKeyApi], classOf[users.social.UserKeyApi.Attempt]), + Pair(classOf[users.social.UserSocialApi], classOf[users.social.UserSocialApi.Attempt]), + Pair(classOf[users.social.UserTokenApi], classOf[users.social.UserTokenApi.Attempt]), + ) + + /** An operation's identity across the two rails: its name and erased parameter types. */ + private type Key = (String, List[Class[?]]) + + /** The public operations a class declares: public, non-synthetic methods returning `Future`, keyed for matching. + * + * Filtering on the `Future` return type is what separates operations from the other public members of a rail — + * the `attempt` accessor and the sub-API accessors return API classes, not futures. Synthetic and bridge methods + * are compiler plumbing, and `name$default$n` methods carry default-argument values, so none of them are + * operations either. + */ + private def operations(cls: Class[?]): Map[Key, Method] = + cls.getDeclaredMethods.toList + .filterNot(method => method.isSynthetic || method.isBridge) + .filterNot(_.getName.contains("$default$")) + .filter(method => Modifier.isPublic(method.getModifiers)) + .filter(_.getReturnType == classOf[Future[?]]) + .map(method => (method.getName, method.getParameterTypes.toList) -> method) + .toMap + + private def operationKeys(cls: Class[?]): Set[Key] = operations(cls).keySet + + /** The `A` in a rail method's `Future[A]`, read off the generic signature so erasure does not hide it. */ + private def futureElement(railMethod: Method): String = + val typeName = railMethod.getGenericReturnType.getTypeName + val prefix = "scala.concurrent.Future<" + assert( + typeName.startsWith(prefix) && typeName.endsWith(">"), + s"${railMethod.getDeclaringClass.getName}.${railMethod.getName}: expected a Future return, saw $typeName", + ) + typeName.drop(prefix.length).dropRight(1) + + /** Every API class reachable from [[CodebergClient]] through public accessors returning `*Api` types. + * + * This is the registry's completeness oracle: sub-APIs such as `client.issues.comments` are found transitively, so + * a newly added group shows up here before anyone remembers to register it above. + */ + private def reachableApis: Set[Class[?]] = + def apiAccessors(cls: Class[?]): Set[Class[?]] = + cls.getDeclaredMethods.toSet + .filterNot(_.isSynthetic) + .filter(method => Modifier.isPublic(method.getModifiers)) + .map(_.getReturnType) + .filter(returned => returned.getName.startsWith("com.worxbend.codeberg4s")) + .filter(_.getSimpleName.endsWith("Api")) + + @scala.annotation.tailrec + def walk(frontier: Set[Class[?]], seen: Set[Class[?]]): Set[Class[?]] = + val discovered = frontier.flatMap(apiAccessors) -- seen + if discovered.isEmpty then seen else walk(discovered, seen ++ discovered) + + walk(Set(classOf[CodebergClient]), Set.empty) + + private def render(keys: Set[Key]): String = + keys.toList + .map((name, params) => s"$name(${params.map(_.getSimpleName).mkString(", ")})") + .sorted + .mkString("; ") From bfaac46afc6c303ceb0e598fd41c6f405df9c1bd Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:02:59 +0300 Subject: [PATCH 03/31] fix(client): scope repository Actions operation ids under repos.actions Repository-scoped Actions operations reported bare ids such as actions.runners.delete, while the organization- and user-scoped variants of the same endpoints already report orgs.actions.* and users.account.actions.*. Because identical leaves exist in all three scopes, telemetry could not tell which scope a call belonged to. Prefix every operation id in RepositoryActionApi and ActionDownloadApi with repos., and update the one core test that pinned the old literal. Operation ids are an observability contract, so this is the last moment to change them: the library is unreleased and nothing external depends on the old spellings yet. --- .../actions/ActionDownloadApi.scala | 4 +- .../actions/RepositoryActionApi.scala | 52 +++++++++---------- .../codeberg4s/core/BinaryPipelineSuite.scala | 4 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala index 15a592a..19a251f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala @@ -67,10 +67,10 @@ final class ActionDownloadApi private[codeberg4s] ( object ActionDownloadApi: /** The stable operation id [[ActionDownloadApi.artifact]] copies into every failure's `CallContext`. */ - val DownloadArtifactOperation: String = "actions.artifacts.download" + val DownloadArtifactOperation: String = "repos.actions.artifacts.download" /** The stable operation id [[ActionDownloadApi.runLogs]] copies into every failure's `CallContext`. */ - val DownloadRunLogsOperation: String = "actions.runs.logs.download" + val DownloadRunLogsOperation: String = "repos.actions.runs.logs.download" /** The typed rail: both operations with [[com.worxbend.codeberg4s.CodebergError]] as a value. */ final class Attempt private[codeberg4s] (rail: ActionDownloadApi)(using exec: Exec[Future]): diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala index 7866ff5..0465c84 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala @@ -516,82 +516,82 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur object RepositoryActionApi: /** The stable operation id of [[RepositoryActionApi.listArtifacts]]. Safe to alert on. */ - val ListArtifactsOperation: String = "actions.artifacts.list" + val ListArtifactsOperation: String = "repos.actions.artifacts.list" /** The stable operation id of the single-artifact read on [[RepositoryActionApi]]. */ - val GetArtifactOperation: String = "actions.artifacts.get" + val GetArtifactOperation: String = "repos.actions.artifacts.get" /** The stable operation id of [[RepositoryActionApi.deleteArtifact]]. */ - val DeleteArtifactOperation: String = "actions.artifacts.delete" + val DeleteArtifactOperation: String = "repos.actions.artifacts.delete" /** The stable operation id of [[RepositoryActionApi.listRuns]]. */ - val ListRunsOperation: String = "actions.runs.list" + val ListRunsOperation: String = "repos.actions.runs.list" /** The stable operation id of the single-run read on [[RepositoryActionApi]]. */ - val GetRunOperation: String = "actions.runs.get" + val GetRunOperation: String = "repos.actions.runs.get" /** The stable operation id of [[RepositoryActionApi.deleteRun]]. */ - val DeleteRunOperation: String = "actions.runs.delete" + val DeleteRunOperation: String = "repos.actions.runs.delete" /** The stable operation id of [[RepositoryActionApi.cancelRun]]. */ - val CancelRunOperation: String = "actions.runs.cancel" + val CancelRunOperation: String = "repos.actions.runs.cancel" /** The stable operation id of [[RepositoryActionApi.listRunArtifacts]]. */ - val ListRunArtifactsOperation: String = "actions.runs.artifacts.list" + val ListRunArtifactsOperation: String = "repos.actions.runs.artifacts.list" /** The stable operation id of [[RepositoryActionApi.listRunJobs]]. */ - val ListRunJobsOperation: String = "actions.runs.jobs.list" + val ListRunJobsOperation: String = "repos.actions.runs.jobs.list" /** The stable operation id of [[RepositoryActionApi.jobLogs]]. */ - val JobLogsOperation: String = "actions.jobs.logs" + val JobLogsOperation: String = "repos.actions.jobs.logs" /** The stable operation id of [[RepositoryActionApi.listRunners]]. */ - val ListRunnersOperation: String = "actions.runners.list" + val ListRunnersOperation: String = "repos.actions.runners.list" /** The stable operation id of the single-runner read on [[RepositoryActionApi]]. */ - val GetRunnerOperation: String = "actions.runners.get" + val GetRunnerOperation: String = "repos.actions.runners.get" /** The stable operation id of [[RepositoryActionApi.registerRunner]]. */ - val RegisterRunnerOperation: String = "actions.runners.register" + val RegisterRunnerOperation: String = "repos.actions.runners.register" /** The stable operation id of [[RepositoryActionApi.deleteRunner]]. */ - val DeleteRunnerOperation: String = "actions.runners.delete" + val DeleteRunnerOperation: String = "repos.actions.runners.delete" /** The stable operation id of [[RepositoryActionApi.runnerRegistrationToken]]. */ - val RunnerRegistrationTokenOperation: String = "actions.runners.registrationToken" + val RunnerRegistrationTokenOperation: String = "repos.actions.runners.registrationToken" /** The stable operation id of [[RepositoryActionApi.searchRunnerJobs]]. */ - val SearchRunnerJobsOperation: String = "actions.runners.jobs.search" + val SearchRunnerJobsOperation: String = "repos.actions.runners.jobs.search" /** The stable operation id of [[RepositoryActionApi.listTasks]]. */ - val ListTasksOperation: String = "actions.tasks.list" + val ListTasksOperation: String = "repos.actions.tasks.list" /** The stable operation id of [[RepositoryActionApi.listSecrets]]. */ - val ListSecretsOperation: String = "actions.secrets.list" + val ListSecretsOperation: String = "repos.actions.secrets.list" /** The stable operation id of [[RepositoryActionApi.setSecret]]. */ - val SetSecretOperation: String = "actions.secrets.set" + val SetSecretOperation: String = "repos.actions.secrets.set" /** The stable operation id of [[RepositoryActionApi.deleteSecret]]. */ - val DeleteSecretOperation: String = "actions.secrets.delete" + val DeleteSecretOperation: String = "repos.actions.secrets.delete" /** The stable operation id of [[RepositoryActionApi.listVariables]]. */ - val ListVariablesOperation: String = "actions.variables.list" + val ListVariablesOperation: String = "repos.actions.variables.list" /** The stable operation id of the single-variable read on [[RepositoryActionApi]]. */ - val GetVariableOperation: String = "actions.variables.get" + val GetVariableOperation: String = "repos.actions.variables.get" /** The stable operation id of [[RepositoryActionApi.createVariable]]. */ - val CreateVariableOperation: String = "actions.variables.create" + val CreateVariableOperation: String = "repos.actions.variables.create" /** The stable operation id of [[RepositoryActionApi.updateVariable]]. */ - val UpdateVariableOperation: String = "actions.variables.update" + val UpdateVariableOperation: String = "repos.actions.variables.update" /** The stable operation id of [[RepositoryActionApi.deleteVariable]]. */ - val DeleteVariableOperation: String = "actions.variables.delete" + val DeleteVariableOperation: String = "repos.actions.variables.delete" /** The stable operation id of [[RepositoryActionApi.dispatchWorkflow]]. */ - val DispatchWorkflowOperation: String = "actions.workflows.dispatch" + val DispatchWorkflowOperation: String = "repos.actions.workflows.dispatch" /** The typed rail of [[RepositoryActionApi]]: every operation, with [[com.worxbend.codeberg4s.CodebergError]] as a * value. diff --git a/modules/core/test/src/com/worxbend/codeberg4s/core/BinaryPipelineSuite.scala b/modules/core/test/src/com/worxbend/codeberg4s/core/BinaryPipelineSuite.scala index 60758c5..7ca2797 100644 --- a/modules/core/test/src/com/worxbend/codeberg4s/core/BinaryPipelineSuite.scala +++ b/modules/core/test/src/com/worxbend/codeberg4s/core/BinaryPipelineSuite.scala @@ -24,7 +24,7 @@ final class BinaryPipelineSuite extends FunSuite: private val zip: Array[Byte] = Array[Byte](0x50, 0x4B, 0x03, 0x04, 0x00) private val request: CodebergRequest = - CodebergRequest("actions.artifacts.download", HttpMethod.Get, List("repos", "o", "r"), Nil, Nil, None) + CodebergRequest("repos.actions.artifacts.download", HttpMethod.Get, List("repos", "o", "r"), Nil, Nil, None) private final class StubBinaryPort(responses: List[Either[TransportFailure, BinaryResponse]]) extends BinaryHttpPort[Result]: @@ -133,7 +133,7 @@ final class BinaryPipelineSuite extends FunSuite: pipeline(telemetry).callBinary(request, port).toOption.foreach(_ => ()) - assertEquals(telemetry.events, Vector("request actions.artifacts.download", "response 200")) + assertEquals(telemetry.events, Vector("request repos.actions.artifacts.download", "response 200")) test("a telemetry sink that fails does not fail the download it is watching"): val telemetry = RecordingTelemetry(failing = true) From 552f0ec9a46c0d4a01ba5447df3b7b78c1074df5 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:05:59 +0300 Subject: [PATCH 04/31] style(client): reformat AttemptParitySuite scaladoc wrapping Running the project formatter rewrapped two Scaladoc paragraphs that were committed unformatted in the previous change. No code or documentation content changes; only line breaks move. --- .../com/worxbend/codeberg4s/AttemptParitySuite.scala | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala index c392cf4..cd36348 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/AttemptParitySuite.scala @@ -153,10 +153,10 @@ object AttemptParitySuite: /** The public operations a class declares: public, non-synthetic methods returning `Future`, keyed for matching. * - * Filtering on the `Future` return type is what separates operations from the other public members of a rail — - * the `attempt` accessor and the sub-API accessors return API classes, not futures. Synthetic and bridge methods - * are compiler plumbing, and `name$default$n` methods carry default-argument values, so none of them are - * operations either. + * Filtering on the `Future` return type is what separates operations from the other public members of a rail — the + * `attempt` accessor and the sub-API accessors return API classes, not futures. Synthetic and bridge methods are + * compiler plumbing, and `name$default$n` methods carry default-argument values, so none of them are operations + * either. */ private def operations(cls: Class[?]): Map[Key, Method] = cls.getDeclaredMethods.toList @@ -181,8 +181,8 @@ object AttemptParitySuite: /** Every API class reachable from [[CodebergClient]] through public accessors returning `*Api` types. * - * This is the registry's completeness oracle: sub-APIs such as `client.issues.comments` are found transitively, so - * a newly added group shows up here before anyone remembers to register it above. + * This is the registry's completeness oracle: sub-APIs such as `client.issues.comments` are found transitively, so a + * newly added group shows up here before anyone remembers to register it above. */ private def reachableApis: Set[Class[?]] = def apiAccessors(cls: Class[?]): Set[Class[?]] = From 50f5592a2485d0a6665c4d658fe8fd9d004eb2f0 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:05:59 +0300 Subject: [PATCH 05/31] refactor(api)!: name repository sub-resource listings as bare nouns RepositoryApi named its sub-resource listings listBranches, listTags, listCommits, listReleases, listTopics, and listForks, while the other API groups name such listings with a bare noun (OrganizationApi.teams, UserApi.followers). Rename all six, on both the exception rail and the Attempt mirror, so a listing of a sub-resource is always the plural noun and the name `list` is reserved for listing the resource itself. IssueApi.listComments/listLabels/listMilestones keep their prefixed names: on that class the bare nouns are already taken by the comments, labels, and milestones sub-API accessors, so the rename cannot apply there without a collision. Call sites in the test suite, README, and site docs are updated. The library is unreleased, so no deprecation cycle is needed. BREAKING CHANGE: RepositoryApi.listBranches/listTags/listCommits/ listReleases/listTopics/listForks (and the same methods on RepositoryApi.Attempt) are renamed to branches/tags/commits/releases/ topics/forks. Replace each call with the bare-noun name; signatures are otherwise unchanged. --- README.md | 2 +- .../repositories/RepositoryApi.scala | 60 +++++++++---------- .../publishing/RepositoryPublishingApi.scala | 6 +- .../repositories/RepositoryApiSuite.scala | 38 ++++++------ site/src/guides/07-testing-your-code.md | 2 +- site/src/guides/10-troubleshooting.md | 2 +- site/src/reference/api-groups.md | 6 +- 7 files changed, 58 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 8bdaccd..f4f74f7 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ import scala.concurrent.Future val latestTags: Future[Vector[String]] = client.repos - .listReleases(owner, name, PageParams.First) + .releases(owner, name, PageParams.First) .map(page => page.items.map((release: Release) => release.tagName.value)) ``` diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala index a4759f4..6ce2a99 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala @@ -124,7 +124,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many branches it may hold */ - def listBranches(owner: Owner, name: RepoName, params: PageParams): Future[Page[Branch]] = + def branches(owner: Owner, name: RepoName, params: PageParams): Future[Page[Branch]] = pipeline.callPage(RepositoryApi.branchesRequest(owner, name, params), params)(using RepositoryDecoders.branches) /** Reads one branch — `GET /repos/{owner}/{repo}/branches/{branch}`. @@ -161,7 +161,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many tags it may hold */ - def listTags(owner: Owner, name: RepoName, params: PageParams): Future[Page[Tag]] = + def tags(owner: Owner, name: RepoName, params: PageParams): Future[Page[Tag]] = pipeline.callPage(RepositoryApi.tagsRequest(owner, name, params), params)(using RepositoryDecoders.tags) /** Lists commits on the repository's default branch — `GET /repos/{owner}/{repo}/commits`. @@ -181,7 +181,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many commits it may hold */ - def listCommits(owner: Owner, name: RepoName, params: PageParams): Future[Page[Commit]] = + def commits(owner: Owner, name: RepoName, params: PageParams): Future[Page[Commit]] = pipeline.callPage(RepositoryApi.commitsRequest(owner, name, params), params)(using RepositoryDecoders.commits) /** Lists a repository's releases — `GET /repos/{owner}/{repo}/releases`. @@ -199,7 +199,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many releases it may hold */ - def listReleases(owner: Owner, name: RepoName, params: PageParams): Future[Page[Release]] = + def releases(owner: Owner, name: RepoName, params: PageParams): Future[Page[Release]] = pipeline.callPage(RepositoryApi.releasesRequest(owner, name, params), params)(using RepositoryDecoders.releases) /** Reads one release by its identifier — `GET /repos/{owner}/{repo}/releases/{id}`. @@ -238,7 +238,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many topics it may hold */ - def listTopics(owner: Owner, name: RepoName, params: PageParams): Future[Page[String]] = + def topics(owner: Owner, name: RepoName, params: PageParams): Future[Page[String]] = pipeline.callPage(RepositoryApi.topicsRequest(owner, name, params), params)(using RepositoryDecoders.topics) /** Reads a file or a directory — `GET /repos/{owner}/{repo}/contents/{filepath}`. @@ -281,7 +281,7 @@ final class RepositoryApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * @param params * the page to fetch and how many forks it may hold */ - def listForks(owner: Owner, name: RepoName, params: PageParams): Future[Page[Repository]] = + def forks(owner: Owner, name: RepoName, params: PageParams): Future[Page[Repository]] = pipeline.callPage(RepositoryApi.forksRequest(owner, name, params), params)(using RepositoryDecoders.repositories) /** The requests this group issues, and its typed rail. */ @@ -295,31 +295,31 @@ object RepositoryApi: /** The stable operation id of [[RepositoryApi.search]]. */ val SearchOperation: String = "repos.search" - /** The stable operation id of [[RepositoryApi.listBranches]]. */ + /** The stable operation id of [[RepositoryApi.branches]]. */ val ListBranchesOperation: String = "repos.branches.list" /** The stable operation id of [[RepositoryApi.getBranch]]. */ val GetBranchOperation: String = "repos.branches.get" - /** The stable operation id of [[RepositoryApi.listTags]]. */ + /** The stable operation id of [[RepositoryApi.tags]]. */ val ListTagsOperation: String = "repos.tags.list" - /** The stable operation id of [[RepositoryApi.listCommits]]. */ + /** The stable operation id of [[RepositoryApi.commits]]. */ val ListCommitsOperation: String = "repos.commits.list" - /** The stable operation id of [[RepositoryApi.listReleases]]. */ + /** The stable operation id of [[RepositoryApi.releases]]. */ val ListReleasesOperation: String = "repos.releases.list" /** The stable operation id of [[RepositoryApi.getRelease]]. */ val GetReleaseOperation: String = "repos.releases.get" - /** The stable operation id of [[RepositoryApi.listTopics]]. */ + /** The stable operation id of [[RepositoryApi.topics]]. */ val ListTopicsOperation: String = "repos.topics.list" /** The stable operation id of [[RepositoryApi.getContents]]. */ val GetContentsOperation: String = "repos.contents.get" - /** The stable operation id of [[RepositoryApi.listForks]]. */ + /** The stable operation id of [[RepositoryApi.forks]]. */ val ListForksOperation: String = "repos.forks.list" /** The typed rail of [[RepositoryApi]]: every operation, with [[com.worxbend.codeberg4s.CodebergError]] as a value. @@ -339,33 +339,33 @@ object RepositoryApi: def search(term: String, params: PageParams): Future[Either[CodebergError, Page[Repository]]] = exec.attempt(rail.search(term, params)) - /** [[RepositoryApi.listBranches]] with its failure as a value. */ - def listBranches(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Branch]]] = - exec.attempt(rail.listBranches(owner, name, params)) + /** [[RepositoryApi.branches]] with its failure as a value. */ + def branches(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Branch]]] = + exec.attempt(rail.branches(owner, name, params)) /** [[RepositoryApi.getBranch]] with its failure as a value. */ def getBranch(owner: Owner, name: RepoName, branch: BranchName): Future[Either[CodebergError, Branch]] = exec.attempt(rail.getBranch(owner, name, branch)) - /** [[RepositoryApi.listTags]] with its failure as a value. */ - def listTags(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Tag]]] = - exec.attempt(rail.listTags(owner, name, params)) + /** [[RepositoryApi.tags]] with its failure as a value. */ + def tags(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Tag]]] = + exec.attempt(rail.tags(owner, name, params)) - /** [[RepositoryApi.listCommits]] with its failure as a value. */ - def listCommits(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Commit]]] = - exec.attempt(rail.listCommits(owner, name, params)) + /** [[RepositoryApi.commits]] with its failure as a value. */ + def commits(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Commit]]] = + exec.attempt(rail.commits(owner, name, params)) - /** [[RepositoryApi.listReleases]] with its failure as a value. */ - def listReleases(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Release]]] = - exec.attempt(rail.listReleases(owner, name, params)) + /** [[RepositoryApi.releases]] with its failure as a value. */ + def releases(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Release]]] = + exec.attempt(rail.releases(owner, name, params)) /** [[RepositoryApi.getRelease]] with its failure as a value. */ def getRelease(owner: Owner, name: RepoName, id: ReleaseId): Future[Either[CodebergError, Release]] = exec.attempt(rail.getRelease(owner, name, id)) - /** [[RepositoryApi.listTopics]] with its failure as a value. */ - def listTopics(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[String]]] = - exec.attempt(rail.listTopics(owner, name, params)) + /** [[RepositoryApi.topics]] with its failure as a value. */ + def topics(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[String]]] = + exec.attempt(rail.topics(owner, name, params)) /** [[RepositoryApi.getContents]] with its failure as a value. */ def getContents( @@ -375,9 +375,9 @@ object RepositoryApi: ): Future[Either[CodebergError, RepositoryContent]] = exec.attempt(rail.getContents(owner, name, path)) - /** [[RepositoryApi.listForks]] with its failure as a value. */ - def listForks(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Repository]]] = - exec.attempt(rail.listForks(owner, name, params)) + /** [[RepositoryApi.forks]] with its failure as a value. */ + def forks(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Repository]]] = + exec.attempt(rail.forks(owner, name, params)) private def getRequest(owner: Owner, name: RepoName): CodebergRequest = read(GetOperation, List("repos", owner.value, name.value), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala index 4ba2a4d..36b60e8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala @@ -113,7 +113,7 @@ final class RepositoryPublishingApi private[codeberg4s] (pipeline: ApiPipeline[F /** Reads the newest published release — `GET /repos/{owner}/{repo}/releases/latest`. * * '''"Latest" is Forgejo's answer, not this library's.''' It excludes drafts and prereleases, so a repository whose - * only releases are prereleases answers `404` here while `client.repos.listReleases` returns them. That is the + * only releases are prereleases answers `404` here while `client.repos.releases` returns them. That is the * endpoint's own contract and is not worked around. * * '''Failures.''' The group contract above; `404` additionally covers "the repository exists and has no release that @@ -289,8 +289,8 @@ final class RepositoryPublishingApi private[codeberg4s] (pipeline: ApiPipeline[F /** Reads one tag — `GET /repos/{owner}/{repo}/tags/{tag}`. * - * The same `Tag` model `client.repos.listTags` returns, one at a time. The tag reaches the wire as several path - * segments when it contains `/`, as in [[releaseByTag]]. + * The same `Tag` model `client.repos.tags` returns, one at a time. The tag reaches the wire as several path segments + * when it contains `/`, as in [[releaseByTag]]. * * '''Failures.''' The group contract above. */ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala index 15a2f74..7dccd15 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala @@ -46,10 +46,10 @@ final class RepositoryApiSuite extends FunSuite: dialling(RepositoryApiSuite.SearchBody)(_.repos.search("forgejo", FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/search?q=forgejo&page=1&limit=30") - test("listBranches dials the branches collection with the requested window"): + test("branches dials the branches collection with the requested window"): val second = PageParams(FirstPage.page.next, orFail(PageSize.from(50))) - dialling("[]")(_.repos.listBranches(Handle, Name, second)): uri => + dialling("[]")(_.repos.branches(Handle, Name, second)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/branches?page=2&limit=50") test("getBranch sends a slashed branch name as real path segments, because Forgejo routes it that way"): @@ -70,24 +70,24 @@ final class RepositoryApiSuite extends FunSuite: dialling(RepositoryApiSuite.ReleaseBody)(_.repos.getRelease(Handle, Name, id)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/releases/11189746") - test("listTags dials the tags collection"): - dialling("[]")(_.repos.listTags(Handle, Name, FirstPage)): uri => + test("tags dials the tags collection"): + dialling("[]")(_.repos.tags(Handle, Name, FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/tags?page=1&limit=30") - test("listCommits dials the commits collection"): - dialling("[]")(_.repos.listCommits(Handle, Name, FirstPage)): uri => + test("commits dials the commits collection"): + dialling("[]")(_.repos.commits(Handle, Name, FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/commits?page=1&limit=30") - test("listReleases dials the releases collection"): - dialling("[]")(_.repos.listReleases(Handle, Name, FirstPage)): uri => + test("releases dials the releases collection"): + dialling("[]")(_.repos.releases(Handle, Name, FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/releases?page=1&limit=30") - test("listForks dials the forks collection"): - dialling("[]")(_.repos.listForks(Handle, Name, FirstPage)): uri => + test("forks dials the forks collection"): + dialling("[]")(_.repos.forks(Handle, Name, FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/forks?page=1&limit=30") - test("listTopics dials the topics collection"): - dialling(RepositoryApiSuite.TopicsBody)(_.repos.listTopics(Handle, Name, FirstPage)): uri => + test("topics dials the topics collection"): + dialling(RepositoryApiSuite.TopicsBody)(_.repos.topics(Handle, Name, FirstPage)): uri => assertEquals(uri, "https://forge.example/api/v1/repos/forgejo/forgejo/topics?page=1&limit=30") // --- paging --------------------------------------------------------------- @@ -99,7 +99,7 @@ final class RepositoryApiSuite extends FunSuite: ) onStub(responding(200, RepositoryApiSuite.OneTag, headers)): client => - client.repos.listTags(Handle, Name, PageParams(FirstPage.page, orFail(PageSize.from(50)))).map: page => + client.repos.tags(Handle, Name, PageParams(FirstPage.page, orFail(PageSize.from(50)))).map: page => assertEquals(page.size, 1, "one item against a window of fifty") assertEquals(page.nextPage.map(_.value), Some(2), "a short page is not the last page") assertEquals(page.isLast, false) @@ -107,19 +107,19 @@ final class RepositoryApiSuite extends FunSuite: test("a response with no Link header is the last page"): onStub(responding(200, RepositoryApiSuite.OneTag, Nil)): client => - client.repos.listTags(Handle, Name, FirstPage).map: page => + client.repos.tags(Handle, Name, FirstPage).map: page => assertEquals(page.isLast, true) assertEquals(page.totalCount, None, "an absent x-total-count is unknown, not zero") test("a page past the end is an empty page rather than a failure"): onStub(responding(200, "[]", Nil)): client => - client.repos.listBranches(Handle, Name, FirstPage).map: page => + client.repos.branches(Handle, Name, FirstPage).map: page => assertEquals(page.items, Vector.empty[Branch]) assertEquals(page.isLast, true) test("the requested window is kept on the page, so the caller can resume"): onStub(responding(200, "[]", Nil)): client => - client.repos.listBranches(Handle, Name, FirstPage).map(page => assertEquals(page.params, FirstPage)) + client.repos.branches(Handle, Name, FirstPage).map(page => assertEquals(page.params, FirstPage)) // --- payloads ------------------------------------------------------------- @@ -128,9 +128,9 @@ final class RepositoryApiSuite extends FunSuite: client.repos.search("forgejo", FirstPage).map: page => assertEquals(page.items.map(_.slug.value), Vector("forgejo/forgejo")) - test("listTopics unwraps the topics envelope into a page of names"): + test("topics unwraps the topics envelope into a page of names"): onStub(responding(200, RepositoryApiSuite.TopicsBody, Nil)): client => - client.repos.listTopics(Handle, Name, FirstPage).map: page => + client.repos.topics(Handle, Name, FirstPage).map: page => assertEquals(page.items, Vector("forge", "git")) test("getContents answers the object arm as a file"): @@ -177,7 +177,7 @@ final class RepositoryApiSuite extends FunSuite: test("a failing listing carries its own operation id, so alerts can tell the endpoints apart"): onStub(responding(404, RepositoryApiSuite.NotFoundBody, Nil)): client => - client.repos.attempt.listCommits(Handle, Name, FirstPage).map: result => + client.repos.attempt.commits(Handle, Name, FirstPage).map: result => assertEquals(operationOf(result), Some(RepositoryApi.ListCommitsOperation)) // --- assertions ----------------------------------------------------------- diff --git a/site/src/guides/07-testing-your-code.md b/site/src/guides/07-testing-your-code.md index 898f017..74b66cc 100644 --- a/site/src/guides/07-testing-your-code.md +++ b/site/src/guides/07-testing-your-code.md @@ -62,7 +62,7 @@ final class ReleaseChecker(client: CodebergClient)(using ExecutionContext): def latestTag(owner: Owner, name: RepoName): Future[Option[String]] = client.repos - .listReleases(owner, name, com.worxbend.codeberg4s.paging.PageParams.First) + .releases(owner, name, com.worxbend.codeberg4s.paging.PageParams.First) .map(page => page.items.headOption.map(_.tagName.value)) ``` diff --git a/site/src/guides/10-troubleshooting.md b/site/src/guides/10-troubleshooting.md index b79b825..d792c4b 100644 --- a/site/src/guides/10-troubleshooting.md +++ b/site/src/guides/10-troubleshooting.md @@ -152,7 +152,7 @@ is a security boundary, not a formality: it is the one thing that would let a crafted name climb out of the branch route. If the name is accepted and the call still `404`s, the branch genuinely is not -there under that spelling. Confirm with `client.repos.listBranches`. +there under that spelling. Confirm with `client.repos.branches`. ## Decode failures diff --git a/site/src/reference/api-groups.md b/site/src/reference/api-groups.md index b9a253d..b52534b 100644 --- a/site/src/reference/api-groups.md +++ b/site/src/reference/api-groups.md @@ -47,9 +47,9 @@ base URI really points at an API root. repository, or that reaches into a specialised corner of one, is in a nested group. -**11 operations:** `get`, `search`, `listBranches`, `getBranch`, `listTags`, -`listCommits`, `listReleases`, `getRelease`, `listTopics`, `getContents`, -`listForks` +**11 operations:** `get`, `search`, `branches`, `getBranch`, `tags`, +`commits`, `releases`, `getRelease`, `topics`, `getContents`, +`forks` `getContents` is the one union in the API: the same path answers a file object or an array of directory entries, so it decodes to an ADT rather than to a From 8e2d4485b24afb0081b5e978ddb713230687e956 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:07:25 +0300 Subject: [PATCH 06/31] refactor(api)!: rename every PageParams parameter to params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paging window parameter was named `page` in roughly half of the listing methods and `params` in the other half, sometimes mixed within a single file. Scala callers can pass arguments by name, so a parameter name is part of the public API and the inconsistency forces callers to check each signature before writing `params = ...` or `page = ...`. Standardise on `params` everywhere — public methods, Attempt mirrors, and private request helpers. `params` is the more accurate name: PageParams carries both the page number and the page size, whereas `page` reads as if it were the number alone. RepositoryWikiApi.page, the single-wiki-page read, keeps its name — it is a method, not a PageParams parameter. The library is unreleased, so no deprecation cycle is needed. BREAKING CHANGE: listing methods that declared `page: PageParams` now declare `params: PageParams`. Positional call sites are unaffected; named-argument call sites must say `params = ...` instead of `page = ...`. --- .../worxbend/codeberg4s/issues/IssueApi.scala | 97 ++++++++++--------- .../codeberg4s/issues/IssueCommentApi.scala | 12 +-- .../codeberg4s/issues/IssueReactionApi.scala | 12 +-- .../issues/IssueSubscriptionApi.scala | 12 +-- .../codeberg4s/issues/IssueTimeApi.scala | 13 +-- .../notifications/NotificationApi.scala | 24 ++--- .../actions/OrganizationActionApi.scala | 36 +++---- .../codeberg4s/pulls/PullRequestApi.scala | 49 +++++----- .../access/RepositoryAccessApi.scala | 24 ++--- .../actions/RepositoryActionApi.scala | 84 ++++++++-------- .../admin/RepositoryAdminApi.scala | 74 +++++++------- .../hooks/RepositoryHookApi.scala | 12 +-- .../hooks/RepositoryWikiApi.scala | 24 ++--- .../publishing/RepositoryPublishingApi.scala | 12 +-- .../users/account/UserAccountApi.scala | 24 ++--- .../users/account/UserActionApi.scala | 24 ++--- .../users/account/UserApplicationApi.scala | 12 +-- .../users/account/UserHookApi.scala | 12 +-- .../users/account/UserQuotaApi.scala | 36 +++---- 19 files changed, 299 insertions(+), 294 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala index cd5e453..feb6b55 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala @@ -122,8 +122,8 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * @param page * which window to fetch, and how large */ - def list(owner: Owner, name: RepoName, query: IssueQuery, page: PageParams): Future[Page[Issue]] = - pipeline.callPage(IssueApi.listRequest(owner, name, query, page), page)(using IssueApi.IssuesDecoder) + def list(owner: Owner, name: RepoName, query: IssueQuery, params: PageParams): Future[Page[Issue]] = + pipeline.callPage(IssueApi.listRequest(owner, name, query, params), params)(using IssueApi.IssuesDecoder) /** Reads one issue — `GET /repos/{owner}/{repo}/issues/{index}`. * @@ -189,8 +189,8 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * '''Failures.''' The group contract above, plus `500`, which the spec declares for this operation and which arrives * as [[com.worxbend.codeberg4s.CodebergError.Api]] like any other status. */ - def listComments(owner: Owner, name: RepoName, number: IssueNumber, page: PageParams): Future[Page[Comment]] = - pipeline.callPage(IssueApi.listCommentsRequest(owner, name, number, page), page)(using IssueApi.CommentsDecoder) + def listComments(owner: Owner, name: RepoName, number: IssueNumber, params: PageParams): Future[Page[Comment]] = + pipeline.callPage(IssueApi.listCommentsRequest(owner, name, number, params), params)(using IssueApi.CommentsDecoder) /** Comments on an issue — `POST /repos/{owner}/{repo}/issues/{index}/comments`. * @@ -220,8 +220,8 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * * '''Failures.''' The group contract above. */ - def listLabels(owner: Owner, name: RepoName, page: PageParams): Future[Page[Label]] = - pipeline.callPage(IssueApi.listLabelsRequest(owner, name, page), page)(using IssueApi.LabelsDecoder) + def listLabels(owner: Owner, name: RepoName, params: PageParams): Future[Page[Label]] = + pipeline.callPage(IssueApi.listLabelsRequest(owner, name, params), params)(using IssueApi.LabelsDecoder) /** Creates a label on a repository — `POST /repos/{owner}/{repo}/labels`. * @@ -247,8 +247,9 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * which milestones to include. Explicit rather than optional here, unlike [[IssueQuery.state]], because the * endpoint has no other filter worth naming and Forgejo's silent default of open-only surprises callers */ - def listMilestones(owner: Owner, name: RepoName, state: StateFilter, page: PageParams): Future[Page[Milestone]] = - pipeline.callPage(IssueApi.listMilestonesRequest(owner, name, state, page), page)(using IssueApi.MilestonesDecoder) + def listMilestones(owner: Owner, name: RepoName, state: StateFilter, params: PageParams): Future[Page[Milestone]] = + pipeline.callPage(IssueApi.listMilestonesRequest(owner, name, state, params), params)(using + IssueApi.MilestonesDecoder) /** Reads one milestone — `GET /repos/{owner}/{repo}/milestones/{id}`. * @@ -283,8 +284,8 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * @param query * the filters to apply; [[IssueSearchQuery.Empty]] asks for the instance's default, which is open issues */ - def search(query: IssueSearchQuery, page: PageParams): Future[Page[Issue]] = - pipeline.callPage(IssueApi.searchRequest(query, page), page)(using IssueDecoders.issues) + def search(query: IssueSearchQuery, params: PageParams): Future[Page[Issue]] = + pipeline.callPage(IssueApi.searchRequest(query, params), params)(using IssueDecoders.issues) /** Deletes an issue — `DELETE /repos/{owner}/{repo}/issues/{index}`. * @@ -391,9 +392,9 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Page[Issue]] = - pipeline.callPage(IssueApi.listBlocksRequest(owner, name, number, page), page)(using IssueDecoders.issues) + pipeline.callPage(IssueApi.listBlocksRequest(owner, name, number, params), params)(using IssueDecoders.issues) /** Declares that this issue blocks another — `POST /repos/{owner}/{repo}/issues/{index}/blocks`. * @@ -453,9 +454,9 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Page[Issue]] = - pipeline.callPage(IssueApi.listDependenciesRequest(owner, name, number, page), page)(using IssueDecoders.issues) + pipeline.callPage(IssueApi.listDependenciesRequest(owner, name, number, params), params)(using IssueDecoders.issues) /** Declares that this issue depends on another — `POST /repos/{owner}/{repo}/issues/{index}/dependencies`. * @@ -515,9 +516,9 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e name: RepoName, number: IssueNumber, query: CommentQuery, - page: PageParams, + params: PageParams, ): Future[Page[TimelineEvent]] = - pipeline.callPage(IssueApi.timelineRequest(owner, name, number, query, page), page)(using IssueDecoders.timeline) + pipeline.callPage(IssueApi.timelineRequest(owner, name, number, query, params), params)(using IssueDecoders.timeline) /** The requests this group issues, its operation ids, and its typed rail. */ object IssueApi: @@ -605,9 +606,9 @@ object IssueApi: owner: Owner, name: RepoName, query: IssueQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Issue]]] = - exec.attempt(rail.list(owner, name, query, page)) + exec.attempt(rail.list(owner, name, query, params)) /** The single-issue read on [[IssueApi]], with its failure as a value. */ def get(owner: Owner, name: RepoName, number: IssueNumber): Future[Either[CodebergError, Issue]] = @@ -631,9 +632,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Comment]]] = - exec.attempt(rail.listComments(owner, name, number, page)) + exec.attempt(rail.listComments(owner, name, number, params)) /** [[IssueApi.createComment]] with its failure as a value. */ def createComment( @@ -645,8 +646,8 @@ object IssueApi: exec.attempt(rail.createComment(owner, name, number, command)) /** [[IssueApi.listLabels]] with its failure as a value. */ - def listLabels(owner: Owner, name: RepoName, page: PageParams): Future[Either[CodebergError, Page[Label]]] = - exec.attempt(rail.listLabels(owner, name, page)) + def listLabels(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Label]]] = + exec.attempt(rail.listLabels(owner, name, params)) /** [[IssueApi.createLabel]] with its failure as a value. */ def createLabel(owner: Owner, name: RepoName, command: CreateLabel): Future[Either[CodebergError, Label]] = @@ -657,17 +658,17 @@ object IssueApi: owner: Owner, name: RepoName, state: StateFilter, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Milestone]]] = - exec.attempt(rail.listMilestones(owner, name, state, page)) + exec.attempt(rail.listMilestones(owner, name, state, params)) /** [[IssueApi.getMilestone]] with its failure as a value. */ def getMilestone(owner: Owner, name: RepoName, id: MilestoneId): Future[Either[CodebergError, Milestone]] = exec.attempt(rail.getMilestone(owner, name, id)) /** [[IssueApi.search]] with its failure as a value. */ - def search(query: IssueSearchQuery, page: PageParams): Future[Either[CodebergError, Page[Issue]]] = - exec.attempt(rail.search(query, page)) + def search(query: IssueSearchQuery, params: PageParams): Future[Either[CodebergError, Page[Issue]]] = + exec.attempt(rail.search(query, params)) /** [[IssueApi.delete]] with its failure as a value. */ def delete(owner: Owner, name: RepoName, number: IssueNumber): Future[Either[CodebergError, Unit]] = @@ -704,9 +705,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Issue]]] = - exec.attempt(rail.listBlocks(owner, name, number, page)) + exec.attempt(rail.listBlocks(owner, name, number, params)) /** [[IssueApi.addBlock]] with its failure as a value. */ def addBlock( @@ -731,9 +732,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Issue]]] = - exec.attempt(rail.listDependencies(owner, name, number, page)) + exec.attempt(rail.listDependencies(owner, name, number, params)) /** [[IssueApi.addDependency]] with its failure as a value. */ def addDependency( @@ -759,15 +760,15 @@ object IssueApi: name: RepoName, number: IssueNumber, query: CommentQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[TimelineEvent]]] = - exec.attempt(rail.timeline(owner, name, number, query, page)) + exec.attempt(rail.timeline(owner, name, number, query, params)) - private def searchRequest(query: IssueSearchQuery, page: PageParams): CodebergRequest = + private def searchRequest(query: IssueSearchQuery, params: PageParams): CodebergRequest = IssueRequests.read( SearchOperation, List("repos", "issues", "search"), - IssueQueries.search(query) ++ IssueQueries.paging(page), + IssueQueries.search(query) ++ IssueQueries.paging(params), ) private def deleteRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = @@ -804,9 +805,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - IssueRequests.read(ListBlocksOperation, blocksPath(owner, name, number), IssueQueries.paging(page)) + IssueRequests.read(ListBlocksOperation, blocksPath(owner, name, number), IssueQueries.paging(params)) private def addBlockRequest( owner: Owner, @@ -837,9 +838,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - IssueRequests.read(ListDependenciesOperation, dependenciesPath(owner, name, number), IssueQueries.paging(page)) + IssueRequests.read(ListDependenciesOperation, dependenciesPath(owner, name, number), IssueQueries.paging(params)) private def addDependencyRequest( owner: Owner, @@ -871,12 +872,12 @@ object IssueApi: name: RepoName, number: IssueNumber, query: CommentQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = IssueRequests.read( TimelineOperation, issuePath(owner, name, number) :+ "timeline", - IssueQueries.comments(query) ++ IssueQueries.paging(page), + IssueQueries.comments(query) ++ IssueQueries.paging(params), ) /** A mutating call with no payload at all, which pinning and moving a pin both are. */ @@ -899,8 +900,8 @@ object IssueApi: private def dependenciesPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = issuePath(owner, name, number) :+ "dependencies" - private def listRequest(owner: Owner, name: RepoName, query: IssueQuery, page: PageParams): CodebergRequest = - read(ListOperation, issuesPath(owner, name), IssueQueries.issues(query) ++ IssueQueries.paging(page)) + private def listRequest(owner: Owner, name: RepoName, query: IssueQuery, params: PageParams): CodebergRequest = + read(ListOperation, issuesPath(owner, name), IssueQueries.issues(query) ++ IssueQueries.paging(params)) private def getRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = read(GetOperation, issuePath(owner, name, number), Nil) @@ -920,9 +921,9 @@ object IssueApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListCommentsOperation, issuePath(owner, name, number) :+ "comments", IssueQueries.paging(page)) + read(ListCommentsOperation, issuePath(owner, name, number) :+ "comments", IssueQueries.paging(params)) private def createCommentRequest( owner: Owner, @@ -937,8 +938,8 @@ object IssueApi: CreateIssueCommentOptionDto.render(command), ) - private def listLabelsRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListLabelsOperation, labelsPath(owner, name), IssueQueries.paging(page)) + private def listLabelsRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListLabelsOperation, labelsPath(owner, name), IssueQueries.paging(params)) private def createLabelRequest(owner: Owner, name: RepoName, command: CreateLabel): CodebergRequest = write(CreateLabelOperation, HttpMethod.Post, labelsPath(owner, name), CreateLabelOptionDto.render(command)) @@ -947,12 +948,12 @@ object IssueApi: owner: Owner, name: RepoName, state: StateFilter, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListMilestonesOperation, milestonesPath(owner, name), - IssueQueries.milestones(state) ++ IssueQueries.paging(page), + IssueQueries.milestones(state) ++ IssueQueries.paging(params), ) private def getMilestoneRequest(owner: Owner, name: RepoName, id: MilestoneId): CodebergRequest = diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala index dc8a6fb..70e2c11 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala @@ -84,9 +84,9 @@ final class IssueCommentApi private[codeberg4s] (pipeline: ApiPipeline[Future])( owner: Owner, name: RepoName, query: CommentQuery, - page: PageParams, + params: PageParams, ): Future[Page[Comment]] = - pipeline.callPage(IssueCommentApi.listForRepositoryRequest(owner, name, query, page), page)(using + pipeline.callPage(IssueCommentApi.listForRepositoryRequest(owner, name, query, params), params)(using IssueDecoders.comments) /** Reads one comment — `GET /repos/{owner}/{repo}/issues/comments/{id}`. @@ -207,9 +207,9 @@ object IssueCommentApi: owner: Owner, name: RepoName, query: CommentQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Comment]]] = - exec.attempt(rail.listForRepository(owner, name, query, page)) + exec.attempt(rail.listForRepository(owner, name, query, params)) /** The single-comment read on [[IssueCommentApi]], with its failure as a value. */ def get(owner: Owner, name: RepoName, id: CommentId): Future[Either[CodebergError, Option[Comment]]] = @@ -251,12 +251,12 @@ object IssueCommentApi: owner: Owner, name: RepoName, query: CommentQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = IssueRequests.read( ListForRepositoryOperation, IssueRequests.issuesPath(owner, name) :+ "comments", - IssueQueries.comments(query) ++ IssueQueries.paging(page), + IssueQueries.comments(query) ++ IssueQueries.paging(params), ) private def getRequest(owner: Owner, name: RepoName, id: CommentId): CodebergRequest = diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala index 7985b81..56008c1 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala @@ -88,9 +88,9 @@ final class IssueReactionApi private[codeberg4s] (pipeline: ApiPipeline[Future]) owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Page[Reaction]] = - pipeline.callPage(IssueReactionApi.listOnIssueRequest(owner, name, number, page), page)(using + pipeline.callPage(IssueReactionApi.listOnIssueRequest(owner, name, number, params), params)(using IssueDecoders.reactions) /** Reacts to an issue — `POST /repos/{owner}/{repo}/issues/{index}/reactions`. @@ -218,9 +218,9 @@ object IssueReactionApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Reaction]]] = - exec.attempt(rail.listOnIssue(owner, name, number, page)) + exec.attempt(rail.listOnIssue(owner, name, number, params)) /** [[IssueReactionApi.addToIssue]] with its failure as a value. */ def addToIssue( @@ -270,9 +270,9 @@ object IssueReactionApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - IssueRequests.read(ListOnIssueOperation, issueReactionsPath(owner, name, number), IssueQueries.paging(page)) + IssueRequests.read(ListOnIssueOperation, issueReactionsPath(owner, name, number), IssueQueries.paging(params)) private def addToIssueRequest( owner: Owner, diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala index 6a2f244..66be8eb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala @@ -79,8 +79,8 @@ final class IssueSubscriptionApi private[codeberg4s] (pipeline: ApiPipeline[Futu * * '''Failures.''' The group contract above. */ - def list(owner: Owner, name: RepoName, number: IssueNumber, page: PageParams): Future[Page[User]] = - pipeline.callPage(IssueSubscriptionApi.listRequest(owner, name, number, page), page)(using IssueDecoders.users) + def list(owner: Owner, name: RepoName, number: IssueNumber, params: PageParams): Future[Page[User]] = + pipeline.callPage(IssueSubscriptionApi.listRequest(owner, name, number, params), params)(using IssueDecoders.users) /** Reports whether the authenticated account follows an issue — * `GET /repos/{owner}/{repo}/issues/{index}/subscriptions/check`. @@ -165,9 +165,9 @@ object IssueSubscriptionApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[User]]] = - exec.attempt(rail.list(owner, name, number, page)) + exec.attempt(rail.list(owner, name, number, params)) /** [[IssueSubscriptionApi.check]] with its failure as a value. */ def check( @@ -199,9 +199,9 @@ object IssueSubscriptionApi: owner: Owner, name: RepoName, number: IssueNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - IssueRequests.read(ListOperation, subscriptionsPath(owner, name, number), IssueQueries.paging(page)) + IssueRequests.read(ListOperation, subscriptionsPath(owner, name, number), IssueQueries.paging(params)) private def checkRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = IssueRequests.read(CheckOperation, subscriptionsPath(owner, name, number) :+ "check", Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala index 248f75a..d761493 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala @@ -145,9 +145,10 @@ final class IssueTimeApi private[codeberg4s] (pipeline: ApiPipeline[Future])(usi name: RepoName, number: IssueNumber, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): Future[Page[TrackedTime]] = - pipeline.callPage(IssueTimeApi.listRequest(owner, name, number, query, page), page)(using IssueDecoders.trackedTimes) + pipeline.callPage(IssueTimeApi.listRequest(owner, name, number, query, params), params)(using + IssueDecoders.trackedTimes) /** Files worked time against an issue — `POST /repos/{owner}/{repo}/issues/{index}/times`. * @@ -249,9 +250,9 @@ object IssueTimeApi: name: RepoName, number: IssueNumber, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[TrackedTime]]] = - exec.attempt(rail.list(owner, name, number, query, page)) + exec.attempt(rail.list(owner, name, number, query, params)) /** [[IssueTimeApi.add]] with its failure as a value. */ def add( @@ -309,12 +310,12 @@ object IssueTimeApi: name: RepoName, number: IssueNumber, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = IssueRequests.read( ListOperation, timesPath(owner, name, number), - IssueQueries.trackedTimes(query) ++ IssueQueries.paging(page), + IssueQueries.trackedTimes(query) ++ IssueQueries.paging(params), ) private def addRequest( diff --git a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala index c801641..55f3047 100644 --- a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala @@ -106,8 +106,8 @@ final class NotificationApi private[codeberg4s] (pipeline: ApiPipeline[Future])( * @param page * which window to fetch, and how large */ - def list(query: NotificationQuery, page: PageParams): Future[Page[NotificationThread]] = - pipeline.callPage(NotificationApi.listRequest(query, page), page)(using NotificationApi.ThreadsDecoder) + def list(query: NotificationQuery, params: PageParams): Future[Page[NotificationThread]] = + pipeline.callPage(NotificationApi.listRequest(query, params), params)(using NotificationApi.ThreadsDecoder) /** Marks the authenticated user's notification threads read — `PUT /notifications`. * @@ -185,9 +185,9 @@ final class NotificationApi private[codeberg4s] (pipeline: ApiPipeline[Future])( owner: Owner, name: RepoName, query: NotificationQuery, - page: PageParams, + params: PageParams, ): Future[Page[NotificationThread]] = - pipeline.callPage(NotificationApi.listRepositoryRequest(owner, name, query, page), page)(using + pipeline.callPage(NotificationApi.listRepositoryRequest(owner, name, query, params), params)(using NotificationApi.ThreadsDecoder) /** Marks the authenticated user's threads for one repository read — `PUT /repos/{owner}/{repo}/notifications`. @@ -236,9 +236,9 @@ object NotificationApi: /** [[NotificationApi.list]] with its failure as a value. */ def list( query: NotificationQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[NotificationThread]]] = - exec.attempt(rail.list(query, page)) + exec.attempt(rail.list(query, params)) /** [[NotificationApi.markAllRead]] with its failure as a value. */ def markAllRead(): Future[Either[CodebergError, Unit]] = @@ -261,9 +261,9 @@ object NotificationApi: owner: Owner, name: RepoName, query: NotificationQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[NotificationThread]]] = - exec.attempt(rail.listRepository(owner, name, query, page)) + exec.attempt(rail.listRepository(owner, name, query, params)) /** [[NotificationApi.markRepositoryRead]] with its failure as a value. */ def markRepositoryRead(owner: Owner, name: RepoName): Future[Either[CodebergError, Unit]] = @@ -274,11 +274,11 @@ object NotificationApi: */ private val NotificationsPath: List[String] = List("notifications") - private def listRequest(query: NotificationQuery, page: PageParams): CodebergRequest = + private def listRequest(query: NotificationQuery, params: PageParams): CodebergRequest = read( ListOperation, NotificationsPath, - NotificationQueries.notifications(query) ++ NotificationQueries.paging(page), + NotificationQueries.notifications(query) ++ NotificationQueries.paging(params), ) private val markAllReadRequest: CodebergRequest = @@ -297,12 +297,12 @@ object NotificationApi: owner: Owner, name: RepoName, query: NotificationQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListRepositoryOperation, repositoryNotificationsPath(owner, name), - NotificationQueries.notifications(query) ++ NotificationQueries.paging(page), + NotificationQueries.notifications(query) ++ NotificationQueries.paging(params), ) private def markRepositoryReadRequest(owner: Owner, name: RepoName): CodebergRequest = diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala index efd92f1..cac2284 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala @@ -131,8 +131,8 @@ final class OrganizationActionApi private[codeberg4s] (pipeline: ApiPipeline[Fut * is always sent, so what a listing contains is a property of the request rather than of the Forgejo version * answering it */ - def listRunners(org: OrgName, visibility: RunnerVisibility, page: PageParams): Future[Page[ActionRunner]] = - pipeline.callPage(OrganizationActionApi.listRunnersRequest(org, visibility, page), page)(using + def listRunners(org: OrgName, visibility: RunnerVisibility, params: PageParams): Future[Page[ActionRunner]] = + pipeline.callPage(OrganizationActionApi.listRunnersRequest(org, visibility, params), params)(using OrganizationActionDecoders.runners) /** Reads one of an organisation's runners — `GET /orgs/{org}/actions/runners/{runner_id}`. @@ -229,8 +229,8 @@ final class OrganizationActionApi private[codeberg4s] (pipeline: ApiPipeline[Fut * * '''Failures.''' The group contract above. */ - def listSecrets(org: OrgName, page: PageParams): Future[Page[ActionSecret]] = - pipeline.callPage(OrganizationActionApi.listSecretsRequest(org, page), page)(using + def listSecrets(org: OrgName, params: PageParams): Future[Page[ActionSecret]] = + pipeline.callPage(OrganizationActionApi.listSecretsRequest(org, params), params)(using OrganizationActionDecoders.secrets) /** Creates or replaces an organisation secret — `PUT /orgs/{org}/actions/secrets/{secretname}`. @@ -288,8 +288,8 @@ final class OrganizationActionApi private[codeberg4s] (pipeline: ApiPipeline[Fut * * '''Failures.''' The group contract above. */ - def listVariables(org: OrgName, page: PageParams): Future[Page[ActionVariable]] = - pipeline.callPage(OrganizationActionApi.listVariablesRequest(org, page), page)(using + def listVariables(org: OrgName, params: PageParams): Future[Page[ActionVariable]] = + pipeline.callPage(OrganizationActionApi.listVariablesRequest(org, params), params)(using OrganizationActionDecoders.variables) /** Reads one organisation variable — `GET /orgs/{org}/actions/variables/{variablename}`. @@ -409,9 +409,9 @@ object OrganizationActionApi: def listRunners( org: OrgName, visibility: RunnerVisibility, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionRunner]]] = - exec.attempt(rail.listRunners(org, visibility, page)) + exec.attempt(rail.listRunners(org, visibility, params)) /** The single-runner read on [[OrganizationActionApi]], with its failure as a value. */ def runner(org: OrgName, id: RunnerId): Future[Either[CodebergError, ActionRunner]] = @@ -437,8 +437,8 @@ object OrganizationActionApi: exec.attempt(rail.searchRunnerJobs(org, labels)) /** [[OrganizationActionApi.listSecrets]] with its failure as a value. */ - def listSecrets(org: OrgName, page: PageParams): Future[Either[CodebergError, Page[ActionSecret]]] = - exec.attempt(rail.listSecrets(org, page)) + def listSecrets(org: OrgName, params: PageParams): Future[Either[CodebergError, Page[ActionSecret]]] = + exec.attempt(rail.listSecrets(org, params)) /** [[OrganizationActionApi.setSecret]] with its failure as a value. */ def setSecret(org: OrgName, secret: SecretName, value: SecretValue): Future[Either[CodebergError, Unit]] = @@ -449,8 +449,8 @@ object OrganizationActionApi: exec.attempt(rail.deleteSecret(org, secret)) /** [[OrganizationActionApi.listVariables]] with its failure as a value. */ - def listVariables(org: OrgName, page: PageParams): Future[Either[CodebergError, Page[ActionVariable]]] = - exec.attempt(rail.listVariables(org, page)) + def listVariables(org: OrgName, params: PageParams): Future[Either[CodebergError, Page[ActionVariable]]] = + exec.attempt(rail.listVariables(org, params)) /** The single-variable read on [[OrganizationActionApi]], with its failure as a value. */ def variable(org: OrgName, name: VariableName): Future[Either[CodebergError, ActionVariable]] = @@ -485,8 +485,8 @@ object OrganizationActionApi: private[actions] def updateVariableEligibility(command: UpdateVariable): RetryEligibility = if command.renamedTo.isEmpty then RetryEligibility.AlwaysRetry else RetryEligibility.Never - private def listRunnersRequest(org: OrgName, visibility: RunnerVisibility, page: PageParams): CodebergRequest = - read(ListRunnersOperation, runnersPath(org), ActionQueries.runners(visibility) ++ ActionQueries.paging(page)) + private def listRunnersRequest(org: OrgName, visibility: RunnerVisibility, params: PageParams): CodebergRequest = + read(ListRunnersOperation, runnersPath(org), ActionQueries.runners(visibility) ++ ActionQueries.paging(params)) private def runnerRequest(org: OrgName, id: RunnerId): CodebergRequest = read(GetRunnerOperation, runnerPath(org, id), Nil) @@ -503,8 +503,8 @@ object OrganizationActionApi: private def searchRunnerJobsRequest(org: OrgName, labels: Vector[RunnerLabel]): CodebergRequest = read(SearchRunnerJobsOperation, runnersPath(org) :+ "jobs", ActionQueries.runnerJobs(labels)) - private def listSecretsRequest(org: OrgName, page: PageParams): CodebergRequest = - read(ListSecretsOperation, secretsPath(org), ActionQueries.paging(page)) + private def listSecretsRequest(org: OrgName, params: PageParams): CodebergRequest = + read(ListSecretsOperation, secretsPath(org), ActionQueries.paging(params)) private def setSecretRequest(org: OrgName, secret: SecretName, value: SecretValue): CodebergRequest = write(SetSecretOperation, HttpMethod.Put, secretPath(org, secret), SecretOptionDto.render(value)) @@ -512,8 +512,8 @@ object OrganizationActionApi: private def deleteSecretRequest(org: OrgName, secret: SecretName): CodebergRequest = remove(DeleteSecretOperation, secretPath(org, secret)) - private def listVariablesRequest(org: OrgName, page: PageParams): CodebergRequest = - read(ListVariablesOperation, variablesPath(org), ActionQueries.paging(page)) + private def listVariablesRequest(org: OrgName, params: PageParams): CodebergRequest = + read(ListVariablesOperation, variablesPath(org), ActionQueries.paging(params)) private def variableRequest(org: OrgName, name: VariableName): CodebergRequest = read(GetVariableOperation, variablePath(org, name), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index e4e8080..eb498c6 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -125,8 +125,8 @@ final class PullRequestApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u * @param page * which window to fetch, and how large */ - def list(owner: Owner, name: RepoName, query: PullRequestQuery, page: PageParams): Future[Page[PullRequest]] = - pipeline.callPage(PullRequestApi.listRequest(owner, name, query, page), page)(using PullRequestApi.PullsDecoder) + def list(owner: Owner, name: RepoName, query: PullRequestQuery, params: PageParams): Future[Page[PullRequest]] = + pipeline.callPage(PullRequestApi.listRequest(owner, name, query, params), params)(using PullRequestApi.PullsDecoder) /** Reads one pull request — `GET /repos/{owner}/{repo}/pulls/{index}`. * @@ -257,9 +257,9 @@ final class PullRequestApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Page[Review]] = - pipeline.callPage(PullRequestApi.reviewsRequest(owner, name, number, page), page)(using + pipeline.callPage(PullRequestApi.reviewsRequest(owner, name, number, params), params)(using PullRequestApi.ReviewsDecoder) /** Lists the commits a pull request would bring — `GET /repos/{owner}/{repo}/pulls/{index}/commits`. @@ -285,9 +285,9 @@ final class PullRequestApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Page[Commit]] = - pipeline.callPage(PullRequestApi.commitsRequest(owner, name, number, page), page)(using + pipeline.callPage(PullRequestApi.commitsRequest(owner, name, number, params), params)(using PullRequestApi.CommitsDecoder) /** Lists the files a pull request changes — `GET /repos/{owner}/{repo}/pulls/{index}/files`. @@ -308,9 +308,10 @@ final class PullRequestApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Page[ChangedFile]] = - pipeline.callPage(PullRequestApi.filesRequest(owner, name, number, page), page)(using PullRequestApi.FilesDecoder) + pipeline.callPage(PullRequestApi.filesRequest(owner, name, number, params), params)(using + PullRequestApi.FilesDecoder) /** Lists the repository's pinned pull requests — `GET /repos/{owner}/{repo}/pulls/pinned`. * @@ -876,9 +877,9 @@ object PullRequestApi: owner: Owner, name: RepoName, query: PullRequestQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[PullRequest]]] = - exec.attempt(rail.list(owner, name, query, page)) + exec.attempt(rail.list(owner, name, query, params)) /** The single-pull-request read on [[PullRequestApi]], with its failure as a value. */ def get( @@ -919,27 +920,27 @@ object PullRequestApi: owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Review]]] = - exec.attempt(rail.listReviews(owner, name, number, page)) + exec.attempt(rail.listReviews(owner, name, number, params)) /** [[PullRequestApi.listCommits]] with its failure as a value. */ def listCommits( owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[Commit]]] = - exec.attempt(rail.listCommits(owner, name, number, page)) + exec.attempt(rail.listCommits(owner, name, number, params)) /** [[PullRequestApi.listFiles]] with its failure as a value. */ def listFiles( owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ChangedFile]]] = - exec.attempt(rail.listFiles(owner, name, number, page)) + exec.attempt(rail.listFiles(owner, name, number, params)) /** [[PullRequestApi.listPinned]] with its failure as a value. */ def listPinned(owner: Owner, name: RepoName): Future[Either[CodebergError, Vector[PullRequest]]] = @@ -1113,9 +1114,9 @@ object PullRequestApi: owner: Owner, name: RepoName, query: PullRequestQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListOperation, pullsPath(owner, name), PullRequestQueries.pulls(query) ++ PullRequestQueries.paging(page)) + read(ListOperation, pullsPath(owner, name), PullRequestQueries.pulls(query) ++ PullRequestQueries.paging(params)) private def getRequest(owner: Owner, name: RepoName, number: PullRequestNumber): CodebergRequest = read(GetOperation, pullPath(owner, name, number), Nil) @@ -1153,25 +1154,25 @@ object PullRequestApi: owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListReviewsOperation, pullPath(owner, name, number) :+ "reviews", PullRequestQueries.paging(page)) + read(ListReviewsOperation, pullPath(owner, name, number) :+ "reviews", PullRequestQueries.paging(params)) private def commitsRequest( owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListCommitsOperation, pullPath(owner, name, number) :+ "commits", PullRequestQueries.paging(page)) + read(ListCommitsOperation, pullPath(owner, name, number) :+ "commits", PullRequestQueries.paging(params)) private def filesRequest( owner: Owner, name: RepoName, number: PullRequestNumber, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListFilesOperation, pullPath(owner, name, number) :+ "files", PullRequestQueries.paging(page)) + read(ListFilesOperation, pullPath(owner, name, number) :+ "files", PullRequestQueries.paging(params)) private def pinnedRequest(owner: Owner, name: RepoName): CodebergRequest = read(ListPinnedOperation, pullsPath(owner, name) :+ "pinned", Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala index 228586a..ef42796 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala @@ -282,8 +282,8 @@ final class RepositoryAccessApi private[codeberg4s] (pipeline: ApiPipeline[Futur * * '''Failures.''' The group contract above. */ - def listCollaborators(owner: Owner, name: RepoName, page: PageParams): Future[Page[User]] = - pipeline.callPage(RepositoryAccessApi.listCollaboratorsRequest(owner, name, page), page)(using + def listCollaborators(owner: Owner, name: RepoName, params: PageParams): Future[Page[User]] = + pipeline.callPage(RepositoryAccessApi.listCollaboratorsRequest(owner, name, params), params)(using RepositoryAccessDecoders.collaborators) /** Asks whether an account is a collaborator — `GET /repos/{owner}/{repo}/collaborators/{collaborator}`. @@ -390,9 +390,9 @@ final class RepositoryAccessApi private[codeberg4s] (pipeline: ApiPipeline[Futur owner: Owner, name: RepoName, query: DeployKeyQuery, - page: PageParams, + params: PageParams, ): Future[Page[DeployKey]] = - pipeline.callPage(RepositoryAccessApi.listDeployKeysRequest(owner, name, query, page), page)(using + pipeline.callPage(RepositoryAccessApi.listDeployKeysRequest(owner, name, query, params), params)(using RepositoryAccessDecoders.deployKeys) /** Reads one deploy key — `GET /repos/{owner}/{repo}/keys/{id}`. @@ -670,9 +670,9 @@ object RepositoryAccessApi: def listCollaborators( owner: Owner, name: RepoName, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[User]]] = - exec.attempt(rail.listCollaborators(owner, name, page)) + exec.attempt(rail.listCollaborators(owner, name, params)) /** [[RepositoryAccessApi.checkCollaborator]] with its failure as a value. * @@ -716,9 +716,9 @@ object RepositoryAccessApi: owner: Owner, name: RepoName, query: DeployKeyQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[DeployKey]]] = - exec.attempt(rail.listDeployKeys(owner, name, query, page)) + exec.attempt(rail.listDeployKeys(owner, name, query, params)) /** The single-key read on [[RepositoryAccessApi]], with its failure as a value. */ def deployKey(owner: Owner, name: RepoName, id: DeployKeyId): Future[Either[CodebergError, DeployKey]] = @@ -820,8 +820,8 @@ object RepositoryAccessApi: private def deleteTagProtectionRequest(owner: Owner, name: RepoName, id: TagProtectionId): CodebergRequest = remove(DeleteTagProtectionOperation, tagProtectionPath(owner, name, id)) - private def listCollaboratorsRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListCollaboratorsOperation, collaboratorsPath(owner, name), AccessQueries.paging(page)) + private def listCollaboratorsRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListCollaboratorsOperation, collaboratorsPath(owner, name), AccessQueries.paging(params)) private def checkCollaboratorRequest(owner: Owner, name: RepoName, collaborator: Username): CodebergRequest = read(CheckCollaboratorOperation, collaboratorPath(owner, name, collaborator), Nil) @@ -849,12 +849,12 @@ object RepositoryAccessApi: owner: Owner, name: RepoName, query: DeployKeyQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListDeployKeysOperation, deployKeysPath(owner, name), - AccessQueries.deployKeys(query) ++ AccessQueries.paging(page), + AccessQueries.deployKeys(query) ++ AccessQueries.paging(params), ) private def deployKeyRequest(owner: Owner, name: RepoName, id: DeployKeyId): CodebergRequest = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala index 0465c84..b78575e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala @@ -114,9 +114,9 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur owner: Owner, name: RepoName, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): Future[Page[ActionArtifact]] = - pipeline.callPage(RepositoryActionApi.listArtifactsRequest(owner, name, query, page), page)(using + pipeline.callPage(RepositoryActionApi.listArtifactsRequest(owner, name, query, params), params)(using RepositoryActionDecoders.artifacts) /** Reads one artifact's metadata — `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}`. @@ -161,8 +161,8 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur * @param query * the filters to apply; [[ActionRunQuery.Empty]] asks for every run */ - def listRuns(owner: Owner, name: RepoName, query: ActionRunQuery, page: PageParams): Future[Page[ActionRun]] = - pipeline.callPage(RepositoryActionApi.listRunsRequest(owner, name, query, page), page)(using + def listRuns(owner: Owner, name: RepoName, query: ActionRunQuery, params: PageParams): Future[Page[ActionRun]] = + pipeline.callPage(RepositoryActionApi.listRunsRequest(owner, name, query, params), params)(using RepositoryActionDecoders.runs) /** Reads one run — `GET /repos/{owner}/{repo}/actions/runs/{run_id}`. @@ -214,9 +214,9 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur name: RepoName, id: RunId, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): Future[Page[ActionArtifact]] = - pipeline.callPage(RepositoryActionApi.listRunArtifactsRequest(owner, name, id, query, page), page)(using + pipeline.callPage(RepositoryActionApi.listRunArtifactsRequest(owner, name, id, query, params), params)(using RepositoryActionDecoders.artifacts) /** Lists the jobs of one run — `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs`. @@ -273,9 +273,9 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur owner: Owner, name: RepoName, visibility: RunnerVisibility, - page: PageParams, + params: PageParams, ): Future[Page[ActionRunner]] = - pipeline.callPage(RepositoryActionApi.listRunnersRequest(owner, name, visibility, page), page)(using + pipeline.callPage(RepositoryActionApi.listRunnersRequest(owner, name, visibility, params), params)(using RepositoryActionDecoders.runners) /** Reads one runner — `GET /repos/{owner}/{repo}/actions/runners/{runner_id}`. @@ -354,8 +354,8 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur * '''Failures.''' The group contract above. This is the one operation in the group for which the spec declares a * `409`, and it arrives as [[com.worxbend.codeberg4s.CodebergError.Api]] like any other status. */ - def listTasks(owner: Owner, name: RepoName, query: ActionTaskQuery, page: PageParams): Future[Page[ActionTask]] = - pipeline.callPage(RepositoryActionApi.listTasksRequest(owner, name, query, page), page)(using + def listTasks(owner: Owner, name: RepoName, query: ActionTaskQuery, params: PageParams): Future[Page[ActionTask]] = + pipeline.callPage(RepositoryActionApi.listTasksRequest(owner, name, query, params), params)(using RepositoryActionDecoders.tasks) // --- secrets -------------------------------------------------------------- @@ -366,8 +366,8 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur * * '''Failures.''' The group contract above. */ - def listSecrets(owner: Owner, name: RepoName, page: PageParams): Future[Page[ActionSecret]] = - pipeline.callPage(RepositoryActionApi.listSecretsRequest(owner, name, page), page)(using + def listSecrets(owner: Owner, name: RepoName, params: PageParams): Future[Page[ActionSecret]] = + pipeline.callPage(RepositoryActionApi.listSecretsRequest(owner, name, params), params)(using RepositoryActionDecoders.secrets) /** Creates or replaces a secret — `PUT /repos/{owner}/{repo}/actions/secrets/{secretname}`. @@ -408,8 +408,8 @@ final class RepositoryActionApi private[codeberg4s] (pipeline: ApiPipeline[Futur * * '''Failures.''' The group contract above. */ - def listVariables(owner: Owner, name: RepoName, page: PageParams): Future[Page[ActionVariable]] = - pipeline.callPage(RepositoryActionApi.listVariablesRequest(owner, name, page), page)(using + def listVariables(owner: Owner, name: RepoName, params: PageParams): Future[Page[ActionVariable]] = + pipeline.callPage(RepositoryActionApi.listVariablesRequest(owner, name, params), params)(using RepositoryActionDecoders.variables) /** Reads one variable — `GET /repos/{owner}/{repo}/actions/variables/{variablename}`. @@ -606,9 +606,9 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionArtifact]]] = - exec.attempt(rail.listArtifacts(owner, name, query, page)) + exec.attempt(rail.listArtifacts(owner, name, query, params)) /** The single-artifact read on [[RepositoryActionApi]], with its failure as a value. */ def artifact(owner: Owner, name: RepoName, id: ArtifactId): Future[Either[CodebergError, ActionArtifact]] = @@ -623,9 +623,9 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ActionRunQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionRun]]] = - exec.attempt(rail.listRuns(owner, name, query, page)) + exec.attempt(rail.listRuns(owner, name, query, params)) /** The single-run read on [[RepositoryActionApi]], with its failure as a value. */ def run(owner: Owner, name: RepoName, id: RunId): Future[Either[CodebergError, ActionRun]] = @@ -645,9 +645,9 @@ object RepositoryActionApi: name: RepoName, id: RunId, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionArtifact]]] = - exec.attempt(rail.listRunArtifacts(owner, name, id, query, page)) + exec.attempt(rail.listRunArtifacts(owner, name, id, query, params)) /** [[RepositoryActionApi.listRunJobs]] with its failure as a value. */ def listRunJobs(owner: Owner, name: RepoName, id: RunId): Future[Either[CodebergError, Vector[ActionRunJob]]] = @@ -667,9 +667,9 @@ object RepositoryActionApi: owner: Owner, name: RepoName, visibility: RunnerVisibility, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionRunner]]] = - exec.attempt(rail.listRunners(owner, name, visibility, page)) + exec.attempt(rail.listRunners(owner, name, visibility, params)) /** The single-runner read on [[RepositoryActionApi]], with its failure as a value. */ def runner(owner: Owner, name: RepoName, id: RunnerId): Future[Either[CodebergError, ActionRunner]] = @@ -707,17 +707,17 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ActionTaskQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionTask]]] = - exec.attempt(rail.listTasks(owner, name, query, page)) + exec.attempt(rail.listTasks(owner, name, query, params)) /** [[RepositoryActionApi.listSecrets]] with its failure as a value. */ def listSecrets( owner: Owner, name: RepoName, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionSecret]]] = - exec.attempt(rail.listSecrets(owner, name, page)) + exec.attempt(rail.listSecrets(owner, name, params)) /** [[RepositoryActionApi.setSecret]] with its failure as a value. */ def setSecret( @@ -736,9 +736,9 @@ object RepositoryActionApi: def listVariables( owner: Owner, name: RepoName, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionVariable]]] = - exec.attempt(rail.listVariables(owner, name, page)) + exec.attempt(rail.listVariables(owner, name, params)) /** The single-variable read on [[RepositoryActionApi]], with its failure as a value. */ def variable( @@ -793,12 +793,12 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListArtifactsOperation, artifactsPath(owner, name), - ActionQueries.artifacts(query) ++ ActionQueries.paging(page), + ActionQueries.artifacts(query) ++ ActionQueries.paging(params), ) private def artifactRequest(owner: Owner, name: RepoName, id: ArtifactId): CodebergRequest = @@ -811,9 +811,9 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ActionRunQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListRunsOperation, runsPath(owner, name), ActionQueries.runs(query) ++ ActionQueries.paging(page)) + read(ListRunsOperation, runsPath(owner, name), ActionQueries.runs(query) ++ ActionQueries.paging(params)) private def runRequest(owner: Owner, name: RepoName, id: RunId): CodebergRequest = read(GetRunOperation, runPath(owner, name, id), Nil) @@ -836,12 +836,12 @@ object RepositoryActionApi: name: RepoName, id: RunId, query: ArtifactQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListRunArtifactsOperation, runPath(owner, name, id) :+ "artifacts", - ActionQueries.artifacts(query) ++ ActionQueries.paging(page), + ActionQueries.artifacts(query) ++ ActionQueries.paging(params), ) private def listRunJobsRequest(owner: Owner, name: RepoName, id: RunId): CodebergRequest = @@ -863,12 +863,12 @@ object RepositoryActionApi: owner: Owner, name: RepoName, visibility: RunnerVisibility, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListRunnersOperation, runnersPath(owner, name), - ActionQueries.runners(visibility) ++ ActionQueries.paging(page), + ActionQueries.runners(visibility) ++ ActionQueries.paging(params), ) private def runnerRequest(owner: Owner, name: RepoName, id: RunnerId): CodebergRequest = @@ -899,16 +899,16 @@ object RepositoryActionApi: owner: Owner, name: RepoName, query: ActionTaskQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListTasksOperation, actionsPath(owner, name) :+ "tasks", - ActionQueries.tasks(query) ++ ActionQueries.paging(page), + ActionQueries.tasks(query) ++ ActionQueries.paging(params), ) - private def listSecretsRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListSecretsOperation, secretsPath(owner, name), ActionQueries.paging(page)) + private def listSecretsRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListSecretsOperation, secretsPath(owner, name), ActionQueries.paging(params)) private def setSecretRequest( owner: Owner, @@ -921,8 +921,8 @@ object RepositoryActionApi: private def deleteSecretRequest(owner: Owner, name: RepoName, secret: SecretName): CodebergRequest = remove(DeleteSecretOperation, secretPath(owner, name, secret)) - private def listVariablesRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListVariablesOperation, variablesPath(owner, name), ActionQueries.paging(page)) + private def listVariablesRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListVariablesOperation, variablesPath(owner, name), ActionQueries.paging(params)) private def variableRequest(owner: Owner, name: RepoName, variableName: VariableName): CodebergRequest = read(GetVariableOperation, variablePath(owner, name, variableName), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala index 7c23871..e9ffe43 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala @@ -296,8 +296,8 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future * * '''Failures.''' The group contract above. */ - def pushMirrors(owner: Owner, name: RepoName, page: PageParams): Future[Page[PushMirror]] = - pipeline.callPage(RepositoryAdminApi.pushMirrorsRequest(owner, name, page), page)(using + def pushMirrors(owner: Owner, name: RepoName, params: PageParams): Future[Page[PushMirror]] = + pipeline.callPage(RepositoryAdminApi.pushMirrorsRequest(owner, name, params), params)(using RepositoryAdminDecoders.pushMirrors) /** Reads one push mirror by its remote name — `GET /repos/{owner}/{repo}/push_mirrors/{name}`. @@ -474,8 +474,9 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future * * '''Failures.''' The group contract above. */ - def stargazers(owner: Owner, name: RepoName, page: PageParams): Future[Page[User]] = - pipeline.callPage(RepositoryAdminApi.stargazersRequest(owner, name, page), page)(using RepositoryAdminDecoders.users) + def stargazers(owner: Owner, name: RepoName, params: PageParams): Future[Page[User]] = + pipeline.callPage(RepositoryAdminApi.stargazersRequest(owner, name, params), params)(using + RepositoryAdminDecoders.users) /** Lists the accounts that watch the repository — `GET /repos/{owner}/{repo}/subscribers`. * @@ -485,8 +486,8 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future * * '''Failures.''' The group contract above. */ - def subscribers(owner: Owner, name: RepoName, page: PageParams): Future[Page[User]] = - pipeline.callPage(RepositoryAdminApi.subscribersRequest(owner, name, page), page)(using + def subscribers(owner: Owner, name: RepoName, params: PageParams): Future[Page[User]] = + pipeline.callPage(RepositoryAdminApi.subscribersRequest(owner, name, params), params)(using RepositoryAdminDecoders.users) // --- branches ------------------------------------------------------------- @@ -684,9 +685,9 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future owner: Owner, name: RepoName, date: Option[LocalDate], - page: PageParams, + params: PageParams, ): Future[Page[RepositoryActivity]] = - pipeline.callPage(RepositoryAdminApi.activityFeedRequest(owner, name, date, page), page)(using + pipeline.callPage(RepositoryAdminApi.activityFeedRequest(owner, name, date, params), params)(using RepositoryAdminDecoders.activities) /** Reads how many bytes of each language the repository holds — `GET /repos/{owner}/{repo}/languages`. @@ -753,9 +754,9 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future owner: Owner, name: RepoName, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): Future[Page[TrackedTime]] = - pipeline.callPage(RepositoryAdminApi.trackedTimesRequest(owner, name, query, page), page)(using + pipeline.callPage(RepositoryAdminApi.trackedTimesRequest(owner, name, query, params), params)(using RepositoryAdminDecoders.trackedTimes) /** Lists one account's tracked time in the repository — `GET /repos/{owner}/{repo}/times/{user}`. @@ -785,8 +786,9 @@ final class RepositoryAdminApi private[codeberg4s] (pipeline: ApiPipeline[Future * @param keyword * the search term, which the spec marks required */ - def searchTopics(keyword: String, page: PageParams): Future[Page[TopicSummary]] = - pipeline.callPage(RepositoryAdminApi.searchTopicsRequest(keyword, page), page)(using RepositoryAdminDecoders.topics) + def searchTopics(keyword: String, params: PageParams): Future[Page[TopicSummary]] = + pipeline.callPage(RepositoryAdminApi.searchTopicsRequest(keyword, params), params)(using + RepositoryAdminDecoders.topics) /** The requests this group issues, its operation ids, and its typed rail. */ object RepositoryAdminApi: @@ -976,8 +978,8 @@ object RepositoryAdminApi: exec.attempt(rail.syncMirror(owner, name)) /** [[RepositoryAdminApi.pushMirrors]] with its failure as a value. */ - def pushMirrors(owner: Owner, name: RepoName, page: PageParams): Future[Either[CodebergError, Page[PushMirror]]] = - exec.attempt(rail.pushMirrors(owner, name, page)) + def pushMirrors(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[PushMirror]]] = + exec.attempt(rail.pushMirrors(owner, name, params)) /** The single push-mirror read on [[RepositoryAdminApi]], with its failure as a value. */ def pushMirror(owner: Owner, name: RepoName, mirror: MirrorName): Future[Either[CodebergError, PushMirror]] = @@ -1040,12 +1042,12 @@ object RepositoryAdminApi: exec.attempt(rail.reviewers(owner, name)) /** [[RepositoryAdminApi.stargazers]] with its failure as a value. */ - def stargazers(owner: Owner, name: RepoName, page: PageParams): Future[Either[CodebergError, Page[User]]] = - exec.attempt(rail.stargazers(owner, name, page)) + def stargazers(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[User]]] = + exec.attempt(rail.stargazers(owner, name, params)) /** [[RepositoryAdminApi.subscribers]] with its failure as a value. */ - def subscribers(owner: Owner, name: RepoName, page: PageParams): Future[Either[CodebergError, Page[User]]] = - exec.attempt(rail.subscribers(owner, name, page)) + def subscribers(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[User]]] = + exec.attempt(rail.subscribers(owner, name, params)) /** [[RepositoryAdminApi.createBranch]] with its failure as a value. */ def createBranch(owner: Owner, name: RepoName, command: CreateBranch): Future[Either[CodebergError, Branch]] = @@ -1120,9 +1122,9 @@ object RepositoryAdminApi: owner: Owner, name: RepoName, date: Option[LocalDate], - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[RepositoryActivity]]] = - exec.attempt(rail.activityFeed(owner, name, date, page)) + exec.attempt(rail.activityFeed(owner, name, date, params)) /** [[RepositoryAdminApi.languages]] with its failure as a value. */ def languages(owner: Owner, name: RepoName): Future[Either[CodebergError, LanguageBreakdown]] = @@ -1145,9 +1147,9 @@ object RepositoryAdminApi: owner: Owner, name: RepoName, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[TrackedTime]]] = - exec.attempt(rail.trackedTimes(owner, name, query, page)) + exec.attempt(rail.trackedTimes(owner, name, query, params)) /** [[RepositoryAdminApi.trackedTimesFor]] with its failure as a value. */ def trackedTimesFor( @@ -1158,8 +1160,8 @@ object RepositoryAdminApi: exec.attempt(rail.trackedTimesFor(owner, name, user)) /** [[RepositoryAdminApi.searchTopics]] with its failure as a value. */ - def searchTopics(keyword: String, page: PageParams): Future[Either[CodebergError, Page[TopicSummary]]] = - exec.attempt(rail.searchTopics(keyword, page)) + def searchTopics(keyword: String, params: PageParams): Future[Either[CodebergError, Page[TopicSummary]]] = + exec.attempt(rail.searchTopics(keyword, params)) private def createRequest(command: CreateRepository): CodebergRequest = write(CreateOperation, HttpMethod.Post, List("user", "repos"), RepositoryOptionDto.renderCreate(command)) @@ -1196,8 +1198,8 @@ object RepositoryAdminApi: private def syncMirrorRequest(owner: Owner, name: RepoName): CodebergRequest = post(SyncMirrorOperation, repoPath(owner, name) :+ "mirror-sync") - private def pushMirrorsRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListPushMirrorsOperation, pushMirrorsPath(owner, name), AdminQueries.paging(page)) + private def pushMirrorsRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListPushMirrorsOperation, pushMirrorsPath(owner, name), AdminQueries.paging(params)) private def pushMirrorRequest(owner: Owner, name: RepoName, mirror: MirrorName): CodebergRequest = read(GetPushMirrorOperation, pushMirrorsPath(owner, name) :+ mirror.value, Nil) @@ -1248,11 +1250,11 @@ object RepositoryAdminApi: private def reviewersRequest(owner: Owner, name: RepoName): CodebergRequest = read(ListReviewersOperation, repoPath(owner, name) :+ "reviewers", Nil) - private def stargazersRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListStargazersOperation, repoPath(owner, name) :+ "stargazers", AdminQueries.paging(page)) + private def stargazersRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListStargazersOperation, repoPath(owner, name) :+ "stargazers", AdminQueries.paging(params)) - private def subscribersRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - read(ListSubscribersOperation, repoPath(owner, name) :+ "subscribers", AdminQueries.paging(page)) + private def subscribersRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + read(ListSubscribersOperation, repoPath(owner, name) :+ "subscribers", AdminQueries.paging(params)) private def createBranchRequest(owner: Owner, name: RepoName, command: CreateBranch): CodebergRequest = write(CreateBranchOperation, HttpMethod.Post, branchesPath(owner, name), BranchOptionDto.renderCreate(command)) @@ -1328,12 +1330,12 @@ object RepositoryAdminApi: owner: Owner, name: RepoName, date: Option[LocalDate], - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListActivityFeedOperation, repoPath(owner, name) ++ List("activities", "feeds"), - AdminQueries.activities(date) ++ AdminQueries.paging(page), + AdminQueries.activities(date) ++ AdminQueries.paging(params), ) private def languagesRequest(owner: Owner, name: RepoName): CodebergRequest = @@ -1352,22 +1354,22 @@ object RepositoryAdminApi: owner: Owner, name: RepoName, query: TrackedTimeQuery, - page: PageParams, + params: PageParams, ): CodebergRequest = read( ListTrackedTimesOperation, timesPath(owner, name), - IssueQueries.trackedTimes(query) ++ AdminQueries.paging(page), + IssueQueries.trackedTimes(query) ++ AdminQueries.paging(params), ) private def trackedTimesForRequest(owner: Owner, name: RepoName, user: Username): CodebergRequest = read(UserTrackedTimesOperation, timesPath(owner, name) :+ user.value, Nil) - private def searchTopicsRequest(keyword: String, page: PageParams): CodebergRequest = + private def searchTopicsRequest(keyword: String, params: PageParams): CodebergRequest = read( SearchTopicsOperation, List("topics", "search"), - AdminQueries.topicSearch(keyword) ++ AdminQueries.paging(page), + AdminQueries.topicSearch(keyword) ++ AdminQueries.paging(params), ) private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala index 5315eba..df8576a 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala @@ -94,8 +94,8 @@ final class RepositoryHookApi private[codeberg4s] (pipeline: ApiPipeline[Future] * * '''Failures.''' The group contract above. */ - def list(owner: Owner, name: RepoName, page: PageParams): Future[Page[Webhook]] = - pipeline.callPage(RepositoryHookApi.listRequest(owner, name, page), page)(using RepositoryHookDecoders.webhooks) + def list(owner: Owner, name: RepoName, params: PageParams): Future[Page[Webhook]] = + pipeline.callPage(RepositoryHookApi.listRequest(owner, name, params), params)(using RepositoryHookDecoders.webhooks) /** Reads one webhook — `GET /repos/{owner}/{repo}/hooks/{id}`. * @@ -275,8 +275,8 @@ object RepositoryHookApi: final class Attempt private[codeberg4s] (rail: RepositoryHookApi)(using exec: Exec[Future]): /** [[RepositoryHookApi.list]] with its failure as a value. */ - def list(owner: Owner, name: RepoName, page: PageParams): Future[Either[CodebergError, Page[Webhook]]] = - exec.attempt(rail.list(owner, name, page)) + def list(owner: Owner, name: RepoName, params: PageParams): Future[Either[CodebergError, Page[Webhook]]] = + exec.attempt(rail.list(owner, name, params)) /** The single-webhook read on [[RepositoryHookApi]], with its failure as a value. */ def get(owner: Owner, name: RepoName, id: HookId): Future[Either[CodebergError, Webhook]] = @@ -329,8 +329,8 @@ object RepositoryHookApi: def deleteGitHook(owner: Owner, name: RepoName, hook: GitHookName): Future[Either[CodebergError, Unit]] = exec.attempt(rail.deleteGitHook(owner, name, hook)) - private def listRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - HookRequests.read(ListOperation, hooksPath(owner, name), HookQueries.paging(page)) + private def listRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + HookRequests.read(ListOperation, hooksPath(owner, name), HookQueries.paging(params)) private def getRequest(owner: Owner, name: RepoName, id: HookId): CodebergRequest = HookRequests.read(GetOperation, hookPath(owner, name, id), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala index cdffee0..763c1d7 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala @@ -77,8 +77,8 @@ final class RepositoryWikiApi private[codeberg4s] (pipeline: ApiPipeline[Future] * * '''Failures.''' The group contract above. */ - def listPages(owner: Owner, name: RepoName, page: PageParams): Future[Page[WikiPageMeta]] = - pipeline.callPage(RepositoryWikiApi.listPagesRequest(owner, name, page), page)(using + def listPages(owner: Owner, name: RepoName, params: PageParams): Future[Page[WikiPageMeta]] = + pipeline.callPage(RepositoryWikiApi.listPagesRequest(owner, name, params), params)(using RepositoryHookDecoders.wikiPages) /** Reads one wiki page with its content — `GET /repos/{owner}/{repo}/wiki/page/{pageName}`. @@ -178,9 +178,9 @@ final class RepositoryWikiApi private[codeberg4s] (pipeline: ApiPipeline[Future] owner: Owner, name: RepoName, pageName: WikiPageName, - page: PageParams, + params: PageParams, ): Future[Page[WikiCommit]] = - pipeline.callPage(RepositoryWikiApi.revisionsRequest(owner, name, pageName, page), page)(using + pipeline.callPage(RepositoryWikiApi.revisionsRequest(owner, name, pageName, params), params)(using RepositoryHookDecoders.wikiRevisions) /** The requests this group issues, its operation ids, and its typed rail. */ @@ -216,9 +216,9 @@ object RepositoryWikiApi: def listPages( owner: Owner, name: RepoName, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[WikiPageMeta]]] = - exec.attempt(rail.listPages(owner, name, page)) + exec.attempt(rail.listPages(owner, name, params)) /** The single-page read on [[RepositoryWikiApi]], with its failure as a value. */ def page(owner: Owner, name: RepoName, pageName: WikiPageName): Future[Either[CodebergError, WikiPage]] = @@ -250,12 +250,12 @@ object RepositoryWikiApi: owner: Owner, name: RepoName, pageName: WikiPageName, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[WikiCommit]]] = - exec.attempt(rail.revisions(owner, name, pageName, page)) + exec.attempt(rail.revisions(owner, name, pageName, params)) - private def listPagesRequest(owner: Owner, name: RepoName, page: PageParams): CodebergRequest = - HookRequests.read(ListPagesOperation, wikiPath(owner, name) :+ "pages", HookQueries.paging(page)) + private def listPagesRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = + HookRequests.read(ListPagesOperation, wikiPath(owner, name) :+ "pages", HookQueries.paging(params)) private def pageRequest(owner: Owner, name: RepoName, pageName: WikiPageName): CodebergRequest = HookRequests.read(GetPageOperation, pagePath(owner, name, pageName), Nil) @@ -288,12 +288,12 @@ object RepositoryWikiApi: owner: Owner, name: RepoName, pageName: WikiPageName, - page: PageParams, + params: PageParams, ): CodebergRequest = HookRequests.read( ListRevisionsOperation, wikiPath(owner, name) ++ ("revisions" :: pageName.segments), - HookQueries.revisionPaging(page), + HookQueries.revisionPaging(params), ) private def wikiPath(owner: Owner, name: RepoName): List[String] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala index 36b60e8..641a64f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala @@ -200,8 +200,8 @@ final class RepositoryPublishingApi private[codeberg4s] (pipeline: ApiPipeline[F * * '''Failures.''' The group contract above. */ - def listAssets(owner: Owner, name: RepoName, id: ReleaseId, page: PageParams): Future[Page[ReleaseAsset]] = - pipeline.callPage(RepositoryPublishingApi.listAssetsRequest(owner, name, id, page), page)(using + def listAssets(owner: Owner, name: RepoName, id: ReleaseId, params: PageParams): Future[Page[ReleaseAsset]] = + pipeline.callPage(RepositoryPublishingApi.listAssetsRequest(owner, name, id, params), params)(using PublishingDecoders.assets) /** Uploads a file and attaches it to a release — `POST /repos/{owner}/{repo}/releases/{id}/assets`. @@ -505,9 +505,9 @@ object RepositoryPublishingApi: owner: Owner, name: RepoName, id: ReleaseId, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ReleaseAsset]]] = - exec.attempt(rail.listAssets(owner, name, id, page)) + exec.attempt(rail.listAssets(owner, name, id, params)) /** [[RepositoryPublishingApi.uploadAsset]] with its failure as a value. */ def uploadAsset( @@ -611,9 +611,9 @@ object RepositoryPublishingApi: owner: Owner, name: RepoName, id: ReleaseId, - page: PageParams, + params: PageParams, ): CodebergRequest = - read(ListAssetsOperation, assetsPath(owner, name, id), window(page)) + read(ListAssetsOperation, assetsPath(owner, name, id), window(params)) private def uploadAssetRequest( owner: Owner, diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala index b0bad6e..57ac87c 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala @@ -241,8 +241,8 @@ final class UserAccountApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u * @param order * how to sort the result; [[RepositoryOrder.Default]] sends no `order_by` and takes the instance's own ordering */ - def repositories(order: RepositoryOrder, page: PageParams): Future[Page[Repository]] = - pipeline.callPage(UserAccountApi.repositoriesRequest(order, page), page)(using UserAccountDecoders.repositories) + def repositories(order: RepositoryOrder, params: PageParams): Future[Page[Repository]] = + pipeline.callPage(UserAccountApi.repositoriesRequest(order, params), params)(using UserAccountDecoders.repositories) /** Creates a repository owned by the account — `POST /user/repos`. * @@ -280,8 +280,8 @@ final class UserAccountApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u * '''Failures.''' The group contract above. [[com.worxbend.codeberg4s.CodebergError.DecodingFailed]] means an * element carried no `id` or `name`, reported at `$[n]`. */ - def teams(page: PageParams): Future[Page[Team]] = - pipeline.callPage(UserAccountApi.teamsRequest(page), page)(using UserAccountDecoders.teams) + def teams(params: PageParams): Future[Page[Team]] = + pipeline.callPage(UserAccountApi.teamsRequest(params), params)(using UserAccountDecoders.teams) /** The requests this group issues, its operation ids, and its typed rail. */ object UserAccountApi: @@ -352,16 +352,16 @@ object UserAccountApi: exec.attempt(rail.deleteEmails(first, rest*)) /** [[UserAccountApi.repositories]] with its failure as a value. */ - def repositories(order: RepositoryOrder, page: PageParams): Future[Either[CodebergError, Page[Repository]]] = - exec.attempt(rail.repositories(order, page)) + def repositories(order: RepositoryOrder, params: PageParams): Future[Either[CodebergError, Page[Repository]]] = + exec.attempt(rail.repositories(order, params)) /** [[UserAccountApi.createRepository]] with its failure as a value. */ def createRepository(command: CreateRepository): Future[Either[CodebergError, Repository]] = exec.attempt(rail.createRepository(command)) /** [[UserAccountApi.teams]] with its failure as a value. */ - def teams(page: PageParams): Future[Either[CodebergError, Page[Team]]] = - exec.attempt(rail.teams(page)) + def teams(params: PageParams): Future[Either[CodebergError, Page[Team]]] = + exec.attempt(rail.teams(params)) private def settingsRequest: CodebergRequest = AccountRequests.read(SettingsOperation, settingsPath, Nil) @@ -399,11 +399,11 @@ object UserAccountApi: private def deleteEmailsRequest(addresses: Vector[EmailAddress]): CodebergRequest = AccountRequests.removeWithBody(DeleteEmailsOperation, emailsPath, AccountOptionDto.renderEmails(addresses)) - private def repositoriesRequest(order: RepositoryOrder, page: PageParams): CodebergRequest = + private def repositoriesRequest(order: RepositoryOrder, params: PageParams): CodebergRequest = AccountRequests.read( RepositoriesOperation, repositoriesPath, - AccountQueries.paging(page) ++ AccountQueries.repositoryOrder(order), + AccountQueries.paging(params) ++ AccountQueries.repositoryOrder(order), ) private def createRepositoryRequest(command: CreateRepository): CodebergRequest = @@ -414,8 +414,8 @@ object UserAccountApi: AccountOptionDto.renderRepository(command), ) - private def teamsRequest(page: PageParams): CodebergRequest = - AccountRequests.read(TeamsOperation, AccountRequests.path("teams"), AccountQueries.paging(page)) + private def teamsRequest(params: PageParams): CodebergRequest = + AccountRequests.read(TeamsOperation, AccountRequests.path("teams"), AccountQueries.paging(params)) private def settingsPath: List[String] = AccountRequests.path("settings") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala index 689e3f5..079ee97 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala @@ -115,8 +115,8 @@ final class UserActionApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * whether to include runners inherited from the instance, or only the account's own — see * [[com.worxbend.codeberg4s.repositories.actions.RunnerVisibility]] for why this is not a `Boolean` */ - def listRunners(visibility: RunnerVisibility, page: PageParams): Future[Page[ActionRunner]] = - pipeline.callPage(UserActionApi.listRunnersRequest(visibility, page), page)(using UserAccountDecoders.runners) + def listRunners(visibility: RunnerVisibility, params: PageParams): Future[Page[ActionRunner]] = + pipeline.callPage(UserActionApi.listRunnersRequest(visibility, params), params)(using UserAccountDecoders.runners) /** Reads one of the account's runners — `GET /user/actions/runners/{runner_id}`. * @@ -242,8 +242,8 @@ final class UserActionApi private[codeberg4s] (pipeline: ApiPipeline[Future])(us * * '''Failures.''' The group contract above. */ - def listVariables(page: PageParams): Future[Page[ActionVariable]] = - pipeline.callPage(UserActionApi.listVariablesRequest(page), page)(using UserAccountDecoders.variables) + def listVariables(params: PageParams): Future[Page[ActionVariable]] = + pipeline.callPage(UserActionApi.listVariablesRequest(params), params)(using UserAccountDecoders.variables) /** Reads one of the account's variables — `GET /user/actions/variables/{variablename}`. * @@ -355,9 +355,9 @@ object UserActionApi: /** [[UserActionApi.listRunners]] with its failure as a value. */ def listRunners( visibility: RunnerVisibility, - page: PageParams, + params: PageParams, ): Future[Either[CodebergError, Page[ActionRunner]]] = - exec.attempt(rail.listRunners(visibility, page)) + exec.attempt(rail.listRunners(visibility, params)) /** The single-runner read on [[UserActionApi]], with its failure as a value. */ def runner(id: RunnerId): Future[Either[CodebergError, ActionRunner]] = @@ -388,8 +388,8 @@ object UserActionApi: exec.attempt(rail.deleteSecret(secret)) /** [[UserActionApi.listVariables]] with its failure as a value. */ - def listVariables(page: PageParams): Future[Either[CodebergError, Page[ActionVariable]]] = - exec.attempt(rail.listVariables(page)) + def listVariables(params: PageParams): Future[Either[CodebergError, Page[ActionVariable]]] = + exec.attempt(rail.listVariables(params)) /** The single-variable read on [[UserActionApi]], with its failure as a value. */ def variable(name: VariableName): Future[Either[CodebergError, ActionVariable]] = @@ -417,11 +417,11 @@ object UserActionApi: private[account] def updateVariableEligibility(command: UpdateVariable): RetryEligibility = if command.renamedTo.isEmpty then RetryEligibility.AlwaysRetry else RetryEligibility.Never - private def listRunnersRequest(visibility: RunnerVisibility, page: PageParams): CodebergRequest = + private def listRunnersRequest(visibility: RunnerVisibility, params: PageParams): CodebergRequest = AccountRequests.read( ListRunnersOperation, runnersPath, - ActionQueries.runners(visibility) ++ ActionQueries.paging(page), + ActionQueries.runners(visibility) ++ ActionQueries.paging(params), ) private def runnerRequest(id: RunnerId): CodebergRequest = @@ -450,8 +450,8 @@ object UserActionApi: private def deleteSecretRequest(secret: SecretName): CodebergRequest = AccountRequests.remove(DeleteSecretOperation, secretPath(secret)) - private def listVariablesRequest(page: PageParams): CodebergRequest = - AccountRequests.read(ListVariablesOperation, variablesPath, ActionQueries.paging(page)) + private def listVariablesRequest(params: PageParams): CodebergRequest = + AccountRequests.read(ListVariablesOperation, variablesPath, ActionQueries.paging(params)) private def variableRequest(name: VariableName): CodebergRequest = AccountRequests.read(GetVariableOperation, variablePath(name), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala index d81dc65..9bb4dd9 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala @@ -86,8 +86,8 @@ final class UserApplicationApi private[codeberg4s] (pipeline: ApiPipeline[Future * * '''Failures.''' The group contract above. */ - def list(page: PageParams): Future[Page[OAuth2Application]] = - pipeline.callPage(UserApplicationApi.listRequest(page), page)(using UserAccountDecoders.applications) + def list(params: PageParams): Future[Page[OAuth2Application]] = + pipeline.callPage(UserApplicationApi.listRequest(params), params)(using UserAccountDecoders.applications) /** Reads one of the account's applications — `GET /user/applications/oauth2/{id}`. * @@ -188,8 +188,8 @@ object UserApplicationApi: final class Attempt private[codeberg4s] (rail: UserApplicationApi)(using exec: Exec[Future]): /** [[UserApplicationApi.list]] with its failure as a value. */ - def list(page: PageParams): Future[Either[CodebergError, Page[OAuth2Application]]] = - exec.attempt(rail.list(page)) + def list(params: PageParams): Future[Either[CodebergError, Page[OAuth2Application]]] = + exec.attempt(rail.list(params)) /** [[UserApplicationApi.get]] with its failure as a value. */ def get(id: OAuth2ApplicationId): Future[Either[CodebergError, OAuth2Application]] = @@ -210,8 +210,8 @@ object UserApplicationApi: def delete(id: OAuth2ApplicationId): Future[Either[CodebergError, Unit]] = exec.attempt(rail.delete(id)) - private def listRequest(page: PageParams): CodebergRequest = - AccountRequests.read(ListOperation, applicationsPath, AccountQueries.paging(page)) + private def listRequest(params: PageParams): CodebergRequest = + AccountRequests.read(ListOperation, applicationsPath, AccountQueries.paging(params)) private def getRequest(id: OAuth2ApplicationId): CodebergRequest = AccountRequests.read(GetOperation, applicationPath(id), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala index b598ad7..5998fb2 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala @@ -90,8 +90,8 @@ final class UserHookApi private[codeberg4s] (pipeline: ApiPipeline[Future])(usin * * '''Failures.''' The group contract above. */ - def list(page: PageParams): Future[Page[Webhook]] = - pipeline.callPage(UserHookApi.listRequest(page), page)(using UserAccountDecoders.webhooks) + def list(params: PageParams): Future[Page[Webhook]] = + pipeline.callPage(UserHookApi.listRequest(params), params)(using UserAccountDecoders.webhooks) /** Reads one of the account's webhooks — `GET /user/hooks/{id}`. * @@ -177,8 +177,8 @@ object UserHookApi: final class Attempt private[codeberg4s] (rail: UserHookApi)(using exec: Exec[Future]): /** [[UserHookApi.list]] with its failure as a value. */ - def list(page: PageParams): Future[Either[CodebergError, Page[Webhook]]] = - exec.attempt(rail.list(page)) + def list(params: PageParams): Future[Either[CodebergError, Page[Webhook]]] = + exec.attempt(rail.list(params)) /** [[UserHookApi.get]] with its failure as a value. */ def get(id: HookId): Future[Either[CodebergError, Webhook]] = @@ -196,8 +196,8 @@ object UserHookApi: def delete(id: HookId): Future[Either[CodebergError, Unit]] = exec.attempt(rail.delete(id)) - private def listRequest(page: PageParams): CodebergRequest = - AccountRequests.read(ListOperation, hooksPath, AccountQueries.paging(page)) + private def listRequest(params: PageParams): CodebergRequest = + AccountRequests.read(ListOperation, hooksPath, AccountQueries.paging(params)) private def getRequest(id: HookId): CodebergRequest = AccountRequests.read(GetOperation, hookPath(id), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala index 53f632d..fe6905c 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala @@ -110,8 +110,8 @@ final class UserQuotaApi private[codeberg4s] (pipeline: ApiPipeline[Future])(usi * * '''Failures.''' The group contract above. */ - def artifacts(page: PageParams): Future[Page[QuotaUsedArtifact]] = - pipeline.callPage(UserQuotaApi.artifactsRequest(page), page)(using UserAccountDecoders.quotaArtifacts) + def artifacts(params: PageParams): Future[Page[QuotaUsedArtifact]] = + pipeline.callPage(UserQuotaApi.artifactsRequest(params), params)(using UserAccountDecoders.quotaArtifacts) /** Lists the attachments counting towards the account's quota — `GET /user/quota/attachments`. * @@ -119,8 +119,8 @@ final class UserQuotaApi private[codeberg4s] (pipeline: ApiPipeline[Future])(usi * * '''Failures.''' The group contract above. */ - def attachments(page: PageParams): Future[Page[QuotaUsedAttachment]] = - pipeline.callPage(UserQuotaApi.attachmentsRequest(page), page)(using UserAccountDecoders.quotaAttachments) + def attachments(params: PageParams): Future[Page[QuotaUsedAttachment]] = + pipeline.callPage(UserQuotaApi.attachmentsRequest(params), params)(using UserAccountDecoders.quotaAttachments) /** Lists the package versions counting towards the account's quota — `GET /user/quota/packages`. * @@ -131,8 +131,8 @@ final class UserQuotaApi private[codeberg4s] (pipeline: ApiPipeline[Future])(usi * * '''Failures.''' The group contract above. */ - def packages(page: PageParams): Future[Page[QuotaUsedPackage]] = - pipeline.callPage(UserQuotaApi.packagesRequest(page), page)(using UserAccountDecoders.quotaPackages) + def packages(params: PageParams): Future[Page[QuotaUsedPackage]] = + pipeline.callPage(UserQuotaApi.packagesRequest(params), params)(using UserAccountDecoders.quotaPackages) /** The requests this group issues, its operation ids, and its typed rail. */ object UserQuotaApi: @@ -169,16 +169,16 @@ object UserQuotaApi: exec.attempt(rail.check(subject)) /** [[UserQuotaApi.artifacts]] with its failure as a value. */ - def artifacts(page: PageParams): Future[Either[CodebergError, Page[QuotaUsedArtifact]]] = - exec.attempt(rail.artifacts(page)) + def artifacts(params: PageParams): Future[Either[CodebergError, Page[QuotaUsedArtifact]]] = + exec.attempt(rail.artifacts(params)) /** [[UserQuotaApi.attachments]] with its failure as a value. */ - def attachments(page: PageParams): Future[Either[CodebergError, Page[QuotaUsedAttachment]]] = - exec.attempt(rail.attachments(page)) + def attachments(params: PageParams): Future[Either[CodebergError, Page[QuotaUsedAttachment]]] = + exec.attempt(rail.attachments(params)) /** [[UserQuotaApi.packages]] with its failure as a value. */ - def packages(page: PageParams): Future[Either[CodebergError, Page[QuotaUsedPackage]]] = - exec.attempt(rail.packages(page)) + def packages(params: PageParams): Future[Either[CodebergError, Page[QuotaUsedPackage]]] = + exec.attempt(rail.packages(params)) private def infoRequest: CodebergRequest = AccountRequests.read(InfoOperation, quotaPath, Nil) @@ -186,14 +186,14 @@ object UserQuotaApi: private def checkRequest(subject: QuotaSubject): CodebergRequest = AccountRequests.read(CheckOperation, quotaPath :+ "check", AccountQueries.quotaCheck(subject)) - private def artifactsRequest(page: PageParams): CodebergRequest = - AccountRequests.read(ArtifactsOperation, quotaPath :+ "artifacts", AccountQueries.paging(page)) + private def artifactsRequest(params: PageParams): CodebergRequest = + AccountRequests.read(ArtifactsOperation, quotaPath :+ "artifacts", AccountQueries.paging(params)) - private def attachmentsRequest(page: PageParams): CodebergRequest = - AccountRequests.read(AttachmentsOperation, quotaPath :+ "attachments", AccountQueries.paging(page)) + private def attachmentsRequest(params: PageParams): CodebergRequest = + AccountRequests.read(AttachmentsOperation, quotaPath :+ "attachments", AccountQueries.paging(params)) - private def packagesRequest(page: PageParams): CodebergRequest = - AccountRequests.read(PackagesOperation, quotaPath :+ "packages", AccountQueries.paging(page)) + private def packagesRequest(params: PageParams): CodebergRequest = + AccountRequests.read(PackagesOperation, quotaPath :+ "packages", AccountQueries.paging(params)) private def quotaPath: List[String] = AccountRequests.path("quota") From bc32fdf1fb9ba5905637c6221a18965419e8ee79 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:10:29 +0300 Subject: [PATCH 07/31] refactor(model)!: move Owner and RepoName to the package root Owner and RepoName lived in com.worxbend.codeberg4s.repositories, but they are not repository-group types: the issues, pulls, notifications, organizations, users, and actions APIs all import them, because almost every endpoint path starts with owner/name. A cross-cutting identifier belongs in the root package, next to PathSegment, the validator both of them delegate to. This moves the two opaque types (and their test suites) up one directory into com.worxbend.codeberg4s and mechanically rewrites every import and fully-qualified Scaladoc link across the domain, codec, client, it, and examples modules, the README, and the site guides. Files that sat in the repositories package itself, and so used the types without an import before, now import them from the root. BREAKING CHANGE: imports of com.worxbend.codeberg4s.repositories.Owner and com.worxbend.codeberg4s.repositories.RepoName must become com.worxbend.codeberg4s.Owner and com.worxbend.codeberg4s.RepoName. The library is pre-0.1.0, so no released version is affected. --- README.md | 8 +++---- .../worxbend/codeberg4s/issues/IssueApi.scala | 9 ++++--- .../issues/IssueAttachmentApi.scala | 4 ++-- .../codeberg4s/issues/IssueCommentApi.scala | 4 ++-- .../codeberg4s/issues/IssueLabelApi.scala | 4 ++-- .../codeberg4s/issues/IssueMilestoneApi.scala | 4 ++-- .../codeberg4s/issues/IssueReactionApi.scala | 4 ++-- .../codeberg4s/issues/IssueRequests.scala | 4 ++-- .../issues/IssueSubscriptionApi.scala | 4 ++-- .../codeberg4s/issues/IssueTimeApi.scala | 4 ++-- .../notifications/NotificationApi.scala | 9 ++++--- .../organizations/OrganizationTeamApi.scala | 2 +- .../codeberg4s/pulls/PullRequestApi.scala | 10 ++++---- .../repositories/RepositoryApi.scala | 2 ++ .../access/RepositoryAccessApi.scala | 4 ++-- .../actions/ActionDownloadApi.scala | 4 ++-- .../actions/RepositoryActionApi.scala | 4 ++-- .../admin/RepositoryAdminApi.scala | 4 ++-- .../gitdata/RepositoryGitApi.scala | 8 +++---- .../hooks/RepositoryFlagApi.scala | 4 ++-- .../hooks/RepositoryHookApi.scala | 4 ++-- .../hooks/RepositoryIssueConfigApi.scala | 4 ++-- .../hooks/RepositoryWikiApi.scala | 4 ++-- .../publishing/RepositoryPublishingApi.scala | 10 ++++---- .../users/social/UserSocialApi.scala | 4 ++-- .../codeberg4s/CodebergClientSuite.scala | 2 -- .../codeberg4s/issues/IssueApiSuite.scala | 4 ++-- .../codeberg4s/issues/IssueLaneHarness.scala | 4 ++-- .../issues/IssueSubscriptionApiSuite.scala | 2 +- .../notifications/NotificationApiSuite.scala | 4 ++-- .../OrganizationAdminApiSuite.scala | 2 +- .../OrganizationTeamApiSuite.scala | 2 +- .../pulls/PullRequestApiSuite.scala | 4 ++-- .../pulls/PullRequestReviewApiSuite.scala | 4 ++-- .../repositories/RepositoryApiSuite.scala | 2 ++ .../access/RepositoryAccessApiSuite.scala | 4 ++-- .../actions/RepositoryActionApiSuite.scala | 4 ++-- .../admin/RepositoryAdminApiSuite.scala | 4 ++-- .../gitdata/RepositoryGitApiSuite.scala | 4 ++-- .../repositories/hooks/HookApiSuite.scala | 4 ++-- .../RepositoryPublishingApiSuite.scala | 4 ++-- .../users/account/UserAccountApiSuite.scala | 2 +- .../users/social/UserSocialApiSuite.scala | 4 ++-- .../users/social/UserTokenApiSuite.scala | 4 ++-- .../issues/wire/RepositoryMetaDto.scala | 4 ++-- .../actions/wire/ActionRepoRefDto.scala | 4 ++-- .../repositories/wire/BranchDto.scala | 2 +- .../repositories/wire/RepositoryDto.scala | 4 ++-- .../users/account/wire/AccountOptionDto.scala | 2 +- .../users/social/wire/StopWatchDto.scala | 8 +++---- .../codeberg4s/users/wire/UserDto.scala | 4 ++-- .../issues/wire/IssueTailQueriesSuite.scala | 2 +- .../wire/IssueTailRequestBodySuite.scala | 4 ++-- .../pulls/wire/PullRequestBodySuite.scala | 2 +- .../pulls/wire/PullRequestQueriesSuite.scala | 2 +- .../admin/wire/AdminRequestsSuite.scala | 4 ++-- .../wire/PublishingRequestsSuite.scala | 4 ++-- .../account/wire/AccountRequestSuite.scala | 2 +- .../social/wire/SocialRequestSuite.scala | 4 ++-- .../codeberg4s/{repositories => }/Owner.scala | 5 +--- .../com/worxbend/codeberg4s/PathSegment.scala | 8 +++---- .../{repositories => }/RepoName.scala | 5 +--- .../worxbend/codeberg4s/issues/IssueRef.scala | 4 ++-- .../codeberg4s/issues/IssueSearchQuery.scala | 2 +- .../codeberg4s/issues/NumericId.scala | 10 ++++---- .../miscellaneous/MarkdownContext.scala | 6 ++--- .../miscellaneous/MarkupRenderRequest.scala | 10 ++++---- .../miscellaneous/TemplateName.scala | 7 +++--- .../codeberg4s/organizations/OrgName.scala | 19 +++++++-------- .../codeberg4s/pulls/PullRequestHead.scala | 11 ++++----- .../codeberg4s/repositories/BranchName.scala | 11 +++++---- .../codeberg4s/repositories/RepoSlug.scala | 3 +++ .../repositories/admin/CreateRepository.scala | 2 +- .../repositories/admin/EditRepository.scala | 2 +- .../admin/MigrateRepository.scala | 4 ++-- .../repositories/publishing/CreateFork.scala | 4 ++-- .../publishing/GenerateRepository.scala | 4 ++-- .../repositories/publishing/Topic.scala | 4 ++-- .../com/worxbend/codeberg4s/users/User.scala | 2 +- .../worxbend/codeberg4s/users/Username.scala | 12 +++++----- .../users/account/CreateRepository.scala | 10 ++++---- .../worxbend/codeberg4s/IdentifierProps.scala | 2 -- .../{repositories => }/OwnerSuite.scala | 4 +--- .../{repositories => }/RepoNameSuite.scala | 4 +--- .../codeberg4s/issues/FilterTokenSuite.scala | 2 +- .../issues/IssueSearchQuerySuite.scala | 2 +- .../miscellaneous/MarkdownContextSuite.scala | 4 ++-- .../pulls/PullRequestIdentifiersSuite.scala | 2 +- .../pulls/PullRequestQuerySuite.scala | 2 +- .../repositories/RepoSlugSuite.scala | 3 +++ .../admin/AdminCommandsSuite.scala | 4 ++-- .../admin/CreateRepositoryBuilderSuite.scala | 2 +- .../admin/EditRepositoryBuilderSuite.scala | 2 +- .../admin/MigrateRepositoryBuilderSuite.scala | 4 ++-- .../admin/RemoteCredentialSuite.scala | 2 +- .../publishing/PublishingCommandsSuite.scala | 4 ++-- .../users/account/AccountCommandSuite.scala | 2 +- .../users/social/SocialDomainSuite.scala | 4 ++-- .../codeberg4s/examples/CreatingAnIssue.scala | 4 ++-- .../codeberg4s/examples/HandlingErrors.scala | 4 ++-- .../codeberg4s/examples/HelloCodeberg.scala | 4 ++-- .../examples/ObservingRequests.scala | 4 ++-- .../codeberg4s/examples/WalkingPages.scala | 4 ++-- .../codeberg4s/it/ForgejoBootstrap.scala | 2 +- .../it/CodebergLiveSmokeSuite.scala | 4 ++-- .../codeberg4s/it/ForgejoContainerSuite.scala | 2 +- .../codeberg4s/it/ForgejoInstance.scala | 4 ++-- site/src/guides/01-getting-started.md | 12 +++++----- site/src/guides/03-errors.md | 24 +++++++++---------- site/src/guides/04-pagination.md | 24 +++++++++---------- site/src/guides/07-testing-your-code.md | 8 +++---- site/src/guides/08-writing-data.md | 20 ++++++++-------- site/src/guides/10-troubleshooting.md | 4 ++-- 113 files changed, 276 insertions(+), 284 deletions(-) rename modules/domain/src/com/worxbend/codeberg4s/{repositories => }/Owner.scala (86%) rename modules/domain/src/com/worxbend/codeberg4s/{repositories => }/RepoName.scala (85%) rename modules/domain/test/src/com/worxbend/codeberg4s/{repositories => }/OwnerSuite.scala (91%) rename modules/domain/test/src/com/worxbend/codeberg4s/{repositories => }/RepoNameSuite.scala (91%) diff --git a/README.md b/README.md index f4f74f7..dc9bbc3 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -129,8 +129,8 @@ The examples in this section all assume the following are in scope: ```scala import com.worxbend.codeberg4s.CodebergClient -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala index feb6b55..6c5c133 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala @@ -3,6 +3,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline @@ -24,8 +26,6 @@ import com.worxbend.codeberg4s.issues.wire.LabelDto import com.worxbend.codeberg4s.issues.wire.MilestoneDto import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future @@ -54,9 +54,8 @@ import java.time.Instant * - [[com.worxbend.codeberg4s.CodebergError.RetriesExhausted]] when a retryable failure outlived the policy. * * [[com.worxbend.codeberg4s.CodebergError.Validation]] is '''not''' produced by any operation here. Every argument is - * an already-validated type — [[com.worxbend.codeberg4s.repositories.Owner]], [[IssueNumber]], [[LabelName]] — so a - * value that would forge a path or a query parameter is rejected by its own smart constructor before a client is ever - * involved. + * an already-validated type — [[com.worxbend.codeberg4s.Owner]], [[IssueNumber]], [[LabelName]] — so a value that + * would forge a path or a query parameter is rejected by its own smart constructor before a client is ever involved. * * ==Retries== * diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala index fcc6446..05e984e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -9,8 +11,6 @@ import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.EditAttachmentOptionDto import com.worxbend.codeberg4s.issues.wire.IssueQueries -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala index 70e2c11..09bc47f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.issues.wire.EditIssueCommentOptionDto import com.worxbend.codeberg4s.issues.wire.IssueQueries import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala index 9cc791d..fcdf8b2 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala @@ -2,14 +2,14 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.EditLabelOptionDto import com.worxbend.codeberg4s.issues.wire.IssueLabelsOptionDto -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala index 965a237..76ec6e6 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala @@ -2,13 +2,13 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.MilestoneOptionDto -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala index 56008c1..a467a0b 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.issues.wire.EditReactionOptionDto import com.worxbend.codeberg4s.issues.wire.IssueQueries import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala index f52ca8c..8c26833 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala @@ -1,10 +1,10 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.RequestBody -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName /** The request shapes and path prefixes every sub-API of the issue group builds on. * diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala index 66be8eb..ad41af8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.IssueQueries import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.users.User import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala index d761493..4529a0a 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.issues.wire.AddTimeOptionDto import com.worxbend.codeberg4s.issues.wire.IssueQueries import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala index 55f3047..3d69fd6 100644 --- a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala @@ -3,6 +3,8 @@ package com.worxbend.codeberg4s.notifications import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline @@ -15,8 +17,6 @@ import com.worxbend.codeberg4s.notifications.wire.NotificationQueries import com.worxbend.codeberg4s.notifications.wire.NotificationThreadDto import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future @@ -63,9 +63,8 @@ import scala.concurrent.Future * - [[com.worxbend.codeberg4s.CodebergError.RetriesExhausted]] when a retryable failure outlived the policy. * * [[com.worxbend.codeberg4s.CodebergError.Validation]] is '''not''' produced by any operation here. Every argument is - * an already-validated type — [[com.worxbend.codeberg4s.repositories.Owner]], [[NotificationThreadId]] — so a value - * that would forge a path or a query parameter is rejected by its own smart constructor before a client is ever - * involved. + * an already-validated type — [[com.worxbend.codeberg4s.Owner]], [[NotificationThreadId]] — so a value that would + * forge a path or a query parameter is rejected by its own smart constructor before a client is ever involved. * * ==Retries== * diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala index f508620..de2f569 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.organizations.wire.OrganizationQueries import com.worxbend.codeberg4s.organizations.wire.TeamOptionDto import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity import com.worxbend.codeberg4s.users.User diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index eb498c6..423ff04 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -3,6 +3,8 @@ package com.worxbend.codeberg4s.pulls import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline @@ -29,8 +31,6 @@ import com.worxbend.codeberg4s.pulls.wire.ReviewDto import com.worxbend.codeberg4s.pulls.wire.SubmitPullReviewOptionsDto import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.Commit -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.wire.CommitDto import com.worxbend.codeberg4s.repositories.wire.Elements @@ -60,9 +60,9 @@ import scala.concurrent.Future * - [[com.worxbend.codeberg4s.CodebergError.RetriesExhausted]] when a retryable failure outlived the policy. * * [[com.worxbend.codeberg4s.CodebergError.Validation]] is '''not''' produced by any operation here. Every argument is - * an already-validated type — [[com.worxbend.codeberg4s.repositories.Owner]], [[PullRequestNumber]], - * [[PullRequestHead]], [[com.worxbend.codeberg4s.repositories.CommitSha]] — so a value that would forge a path or a - * query parameter is rejected by its own smart constructor before a client is ever involved. + * an already-validated type — [[com.worxbend.codeberg4s.Owner]], [[PullRequestNumber]], [[PullRequestHead]], + * [[com.worxbend.codeberg4s.repositories.CommitSha]] — so a value that would forge a path or a query parameter is + * rejected by its own smart constructor before a client is ever involved. * * ==Retries== * diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala index 6ce2a99..4fe89bb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala index ef42796..2a726b4 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.access import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.Team import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.access.wire.AccessQueries import com.worxbend.codeberg4s.repositories.access.wire.AddCollaboratorOptionDto import com.worxbend.codeberg4s.repositories.access.wire.CreateBranchProtectionOptionDto diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala index 19a251f..00898ce 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala @@ -2,13 +2,13 @@ package com.worxbend.codeberg4s.repositories.actions import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.BinaryHttpPort import com.worxbend.codeberg4s.core.BinaryResponse import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala index b78575e..e27987b 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.actions import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -9,8 +11,6 @@ import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.actions.wire.ActionQueries import com.worxbend.codeberg4s.repositories.actions.wire.DispatchWorkflowOptionDto import com.worxbend.codeberg4s.repositories.actions.wire.RegisterRunnerOptionDto diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala index e9ffe43..2f872cb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.admin import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -18,8 +20,6 @@ import com.worxbend.codeberg4s.repositories.Branch import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.ContentEntry import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.wire.AdminQueries import com.worxbend.codeberg4s.repositories.admin.wire.AvatarOptionDto diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala index 8a727b9..fb4699b 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.gitdata import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -13,8 +15,6 @@ import com.worxbend.codeberg4s.pulls.PullRequest import com.worxbend.codeberg4s.repositories.Commit import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.gitdata.wire.DiffPatchOptionsDto import com.worxbend.codeberg4s.repositories.gitdata.wire.GitDataQueries import com.worxbend.codeberg4s.repositories.gitdata.wire.NoteOptionsDto @@ -36,8 +36,8 @@ import scala.concurrent.Future * [[com.worxbend.codeberg4s.CodebergError.Transport]] means nothing reached the instance, * [[com.worxbend.codeberg4s.CodebergError.RetriesExhausted]] means a retryable failure outlived the policy, and * [[com.worxbend.codeberg4s.CodebergError.DecodingFailed]] means a `2xx` payload did not fit the model, reported at - * the JSON path that did not fit. Arguments are [[com.worxbend.codeberg4s.repositories.Owner]], - * [[com.worxbend.codeberg4s.repositories.RepoName]], [[com.worxbend.codeberg4s.repositories.CommitSha]], [[RefName]], + * the JSON path that did not fit. Arguments are [[com.worxbend.codeberg4s.Owner]], + * [[com.worxbend.codeberg4s.RepoName]], [[com.worxbend.codeberg4s.repositories.CommitSha]], [[RefName]], * [[CompareRange]] and [[com.worxbend.codeberg4s.repositories.ContentPath]] rather than `String`, so a value that * would forge a request path is rejected by its own smart constructor and no operation here produces * [[com.worxbend.codeberg4s.CodebergError.Validation]] for its arguments. Anything an individual operation adds to diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlagApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlagApi.scala index 183bf76..97e03ea 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlagApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlagApi.scala @@ -2,13 +2,13 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.hooks.wire.RepositoryFlagWire import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala index df8576a..2d8a377 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -9,8 +11,6 @@ import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.hooks.wire.HookOptionDto import com.worxbend.codeberg4s.repositories.hooks.wire.HookQueries diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryIssueConfigApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryIssueConfigApi.scala index 81b8322..f03f6a8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryIssueConfigApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryIssueConfigApi.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.CodebergError +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Future diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala index 763c1d7..2785454 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryWikiApi.scala @@ -2,14 +2,14 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.hooks.wire.HookQueries import com.worxbend.codeberg4s.repositories.hooks.wire.WikiPageOptionsDto diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala index 641a64f..1be48ff 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.repositories.publishing import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -9,11 +11,9 @@ import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner import com.worxbend.codeberg4s.repositories.Release import com.worxbend.codeberg4s.repositories.ReleaseAsset import com.worxbend.codeberg4s.repositories.ReleaseId -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.RepositoryDecoders import com.worxbend.codeberg4s.repositories.Tag @@ -59,9 +59,9 @@ import scala.concurrent.Future * - [[com.worxbend.codeberg4s.CodebergError.RetriesExhausted]] when a retryable failure outlived the policy. * * [[com.worxbend.codeberg4s.CodebergError.Validation]] is '''not''' produced by any operation here. Every argument is - * an already-validated type — [[com.worxbend.codeberg4s.repositories.Owner]], - * [[com.worxbend.codeberg4s.repositories.TagName]], [[Topic]], [[AssetId]] — so a value that would forge a path is - * rejected by its own smart constructor before a client is ever involved. + * an already-validated type — [[com.worxbend.codeberg4s.Owner]], [[com.worxbend.codeberg4s.repositories.TagName]], + * [[Topic]], [[AssetId]] — so a value that would forge a path is rejected by its own smart constructor before a client + * is ever involved. * * ==Retries== * diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala index 4b084a9..9033679 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.users.social import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.Exec @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.TrackedTime import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity import com.worxbend.codeberg4s.users.User diff --git a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala index d2de7f2..607b4f3 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala @@ -5,8 +5,6 @@ import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.RepositoryApi import com.worxbend.codeberg4s.retry.Jitter diff --git a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala index 5ae7f77..8f362f0 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -15,8 +17,6 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala index b0a7f82..bb5b4ee 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -15,8 +17,6 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApiSuite.scala index a26cfe3..aa5b61f 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApiSuite.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.repositories.Owner +import com.worxbend.codeberg4s.Owner import sttp.client4.Backend import sttp.client4.testing.RecordingBackend diff --git a/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala index c52e7b6..2fd9015 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -15,8 +17,6 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala index 3ba4d96..0ebcc03 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala @@ -2,8 +2,8 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.admin.ActivityOperation import com.worxbend.codeberg4s.repositories.admin.CreateRepository import com.worxbend.codeberg4s.users.UserVisibility diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala index 18b829c..a6aa448 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.CodebergError +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.RepoName import sttp.client4.testing.RecordingBackend diff --git a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala index a1473d2..592926e 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -19,8 +21,6 @@ import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala index a0b5140..1356d4b 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -14,8 +16,6 @@ import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala index 7dccd15..772ee0a 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/RepositoryApiSuite.scala @@ -5,6 +5,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.paging.PageParams diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala index ed4026b..eaef324 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -16,8 +18,6 @@ import com.worxbend.codeberg4s.organizations.TeamPermission import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala index 73f07c6..270acf1 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala @@ -5,6 +5,8 @@ import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -16,8 +18,6 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala index a991ce2..796c56b 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -19,8 +21,6 @@ import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.gitdata.RefName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala index c0c9514..ab60505 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -17,8 +19,6 @@ import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/hooks/HookApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/hooks/HookApiSuite.scala index de77478..a1525eb 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/hooks/HookApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/hooks/HookApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -15,8 +17,6 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala index 8113020..94febd3 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala @@ -4,6 +4,8 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec @@ -15,9 +17,7 @@ import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner import com.worxbend.codeberg4s.repositories.ReleaseId -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.TagName import com.worxbend.codeberg4s.retry.Jitter import com.worxbend.codeberg4s.retry.RetryPolicy diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala index cfbf180..eeb9ac2 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala @@ -2,8 +2,8 @@ package com.worxbend.codeberg4s.users.account import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.RepoName import sttp.client4.Backend import sttp.client4.testing.RecordingBackend diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala index d2b62f9..618e3b3 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala @@ -2,9 +2,9 @@ package com.worxbend.codeberg4s.users.social import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.users.Username import sttp.client4.Backend diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala index 2bcaeff..a9afbe3 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala @@ -3,11 +3,11 @@ package com.worxbend.codeberg4s.users.social import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.HttpMethod +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.auth.ApiToken import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import com.worxbend.codeberg4s.users.Username diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/RepositoryMetaDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/RepositoryMetaDto.scala index 331a2c1..a21f560 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/RepositoryMetaDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/RepositoryMetaDto.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.issues.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug /** Forgejo's `RepositoryMeta` model — the four-key object an `Issue` carries under `repository`. diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRepoRefDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRepoRefDto.scala index 54c59a2..c6fbc25 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRepoRefDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRepoRefDto.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.repositories.actions.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug /** The `repository` object embedded in an `ActionRun`, read down to the two things a caller needs from it. diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/BranchDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/BranchDto.scala index 8e09533..87419f0 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/BranchDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/BranchDto.scala @@ -13,7 +13,7 @@ import com.worxbend.codeberg4s.repositories.BranchName * Every key on `golden/repository/branch-single.json` and on the three elements of * `golden/repository/branches-list.json` is represented. Two of those branches are named `renovate/…` and `v16.0/…`, * which is the evidence behind [[com.worxbend.codeberg4s.repositories.BranchName]] accepting slashes where - * [[com.worxbend.codeberg4s.repositories.RepoName]] rejects them. + * [[com.worxbend.codeberg4s.RepoName]] rejects them. * * @param name * the `name` key diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala index 6cd49f5..09c250f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.users.wire.UserDto diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountOptionDto.scala index 704d175..5a5195f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountOptionDto.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.users.account.wire +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.users.account.AvatarImage import com.worxbend.codeberg4s.users.account.CreateRepository import com.worxbend.codeberg4s.users.account.EmailAddress diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala index e3be420..8687f16 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.StopWatch @@ -69,8 +69,8 @@ final case class StopWatchDto( * it is what keeps a page of stopwatches from failing over a field the spec never promised. * * '''The repository is lenient.''' `repo_owner_name` and `repo_name` go through - * [[com.worxbend.codeberg4s.repositories.Owner.from]] and [[com.worxbend.codeberg4s.repositories.RepoName.from]], - * and a pair either of them rejects becomes `None` rather than failing the entry — the same trade + * [[com.worxbend.codeberg4s.Owner.from]] and [[com.worxbend.codeberg4s.RepoName.from]], and a pair either of them + * rejects becomes `None` rather than failing the entry — the same trade * [[com.worxbend.codeberg4s.issues.wire.RepositoryMetaDto.toSlug]] makes. The issue number survives, which is the * part a caller acts on. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/wire/UserDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/wire/UserDto.scala index aa53c14..05eb2b3 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/wire/UserDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/wire/UserDto.scala @@ -49,8 +49,8 @@ final case class UserDto( /** Converts to the domain, reporting failure paths relative to `at`. * * Fails only on `id` and `login`. Those two are what every consumer of an embedded user needs — a login is what - * becomes an [[com.worxbend.codeberg4s.repositories.Owner]], and an id is what distinguishes two accounts after a - * rename. Everything else is genuinely optional and stays optional. + * becomes an [[com.worxbend.codeberg4s.Owner]], and an id is what distinguishes two accounts after a rename. + * Everything else is genuinely optional and stays optional. * * Counts absent from the payload become `0` rather than failing: a reduced embedded user carries no * `followers_count`, and reading that as "zero followers" is the same answer the API would give. `visibility` that diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailQueriesSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailQueriesSuite.scala index a5d97f8..cfed917 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailQueriesSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailQueriesSuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues.wire +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.CommentQuery import com.worxbend.codeberg4s.issues.IssueKind @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.issues.MilestoneTitle import com.worxbend.codeberg4s.issues.StateFilter import com.worxbend.codeberg4s.issues.TrackedTimeQuery import com.worxbend.codeberg4s.issues.UploadAttachment -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailRequestBodySuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailRequestBodySuite.scala index 6d77408..ae231c1 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailRequestBodySuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/issues/wire/IssueTailRequestBodySuite.scala @@ -1,5 +1,7 @@ package com.worxbend.codeberg4s.issues.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.AddTrackedTime import com.worxbend.codeberg4s.issues.CreateMilestone @@ -16,8 +18,6 @@ import com.worxbend.codeberg4s.issues.LabelRef import com.worxbend.codeberg4s.issues.LabelRemoval import com.worxbend.codeberg4s.issues.LabelUpdate import com.worxbend.codeberg4s.issues.ReactionContent -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBodySuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBodySuite.scala index 371308e..8ce73a8 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBodySuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBodySuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls.wire +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.LabelId import com.worxbend.codeberg4s.issues.MilestoneId @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.pulls.MergeStyle import com.worxbend.codeberg4s.pulls.PullRequestHead import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueriesSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueriesSuite.scala index 7982bb4..b77e75a 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueriesSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueriesSuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls.wire +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.LabelId import com.worxbend.codeberg4s.issues.MilestoneId @@ -11,7 +12,6 @@ import com.worxbend.codeberg4s.pulls.PullRequestHead import com.worxbend.codeberg4s.pulls.PullRequestQuery import com.worxbend.codeberg4s.pulls.PullRequestSort import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminRequestsSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminRequestsSuite.scala index 8d25272..aa4dde0 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminRequestsSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminRequestsSuite.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.repositories.admin.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.organizations.TeamId import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.admin.AvatarImage import com.worxbend.codeberg4s.repositories.admin.ChangeFiles import com.worxbend.codeberg4s.repositories.admin.CommitDates diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/publishing/wire/PublishingRequestsSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/publishing/wire/PublishingRequestsSuite.scala index d81f3f8..dcef040 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/publishing/wire/PublishingRequestsSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/publishing/wire/PublishingRequestsSuite.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.repositories.publishing.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.TagName import com.worxbend.codeberg4s.repositories.publishing.CreateFork import com.worxbend.codeberg4s.repositories.publishing.CreateRelease diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/users/account/wire/AccountRequestSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/users/account/wire/AccountRequestSuite.scala index 066fe50..639689a 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/users/account/wire/AccountRequestSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/users/account/wire/AccountRequestSuite.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.users.account.wire +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.users.account.AvatarImage import com.worxbend.codeberg4s.users.account.CreateRepository import com.worxbend.codeberg4s.users.account.EmailAddress diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/users/social/wire/SocialRequestSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/users/social/wire/SocialRequestSuite.scala index 87c1a6e..73fd77c 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/users/social/wire/SocialRequestSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/users/social/wire/SocialRequestSuite.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.users.social.wire +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import com.worxbend.codeberg4s.users.social.ActivityFeedQuery import com.worxbend.codeberg4s.users.social.ArmoredSignature diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/Owner.scala b/modules/domain/src/com/worxbend/codeberg4s/Owner.scala similarity index 86% rename from modules/domain/src/com/worxbend/codeberg4s/repositories/Owner.scala rename to modules/domain/src/com/worxbend/codeberg4s/Owner.scala index f525c6a..e4aa0f6 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/Owner.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/Owner.scala @@ -1,7 +1,4 @@ -package com.worxbend.codeberg4s.repositories - -import com.worxbend.codeberg4s.PathSegment -import com.worxbend.codeberg4s.ValidationError +package com.worxbend.codeberg4s /** The user or organisation that owns a repository — the first segment of `owner/name`. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/PathSegment.scala b/modules/domain/src/com/worxbend/codeberg4s/PathSegment.scala index 7496f5d..39097fa 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/PathSegment.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/PathSegment.scala @@ -2,10 +2,10 @@ package com.worxbend.codeberg4s /** Validation shared by every identifier that becomes part of a URI path. * - * This is a security boundary, not a convenience: an identifier such as [[com.worxbend.codeberg4s.repositories.Owner]] - * or [[com.worxbend.codeberg4s.users.Username]] is interpolated into a request path, so a value containing `/` would - * let a caller reach an endpoint the API surface never offered, and a control character would corrupt the request - * line. Both are rejected here, once, rather than at each call site. + * This is a security boundary, not a convenience: an identifier such as [[com.worxbend.codeberg4s.Owner]] or + * [[com.worxbend.codeberg4s.users.Username]] is interpolated into a request path, so a value containing `/` would let + * a caller reach an endpoint the API surface never offered, and a control character would corrupt the request line. + * Both are rejected here, once, rather than at each call site. * * '''It lives in the root package so that "once" is true.''' The rule used to sit in * `com.worxbend.codeberg4s.repositories` and be visible only there, which meant the three identifiers outside that diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoName.scala b/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala similarity index 85% rename from modules/domain/src/com/worxbend/codeberg4s/repositories/RepoName.scala rename to modules/domain/src/com/worxbend/codeberg4s/RepoName.scala index 876a807..cbf75ce 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala @@ -1,7 +1,4 @@ -package com.worxbend.codeberg4s.repositories - -import com.worxbend.codeberg4s.PathSegment -import com.worxbend.codeberg4s.ValidationError +package com.worxbend.codeberg4s /** The repository half of `owner/name`. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueRef.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueRef.scala index 6663717..4ef516b 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueRef.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueRef.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName /** One issue named from outside its own repository — Forgejo's `IssueMeta`, and the body of every blocking and * dependency call. diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueSearchQuery.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueSearchQuery.scala index c64d2a3..bc7dcb7 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueSearchQuery.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueSearchQuery.scala @@ -1,6 +1,6 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.repositories.Owner +import com.worxbend.codeberg4s.Owner import java.time.Instant diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala index 37f8d08..ad9a3d6 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala @@ -5,11 +5,11 @@ import com.worxbend.codeberg4s.ValidationError /** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. * * [[IssueNumber]], [[LabelId]], [[MilestoneId]] and [[CommentId]] are all `int64` on the wire and all end up - * interpolated into a request path. Unlike [[com.worxbend.codeberg4s.repositories.Owner]] a number cannot forge a - * path, so the point here is not escaping but confusion: an issue's per-repository `number` and its instance-wide `id` - * are both `Long` and are routinely mixed up, and `/issues/0` is a request Forgejo answers with a `404` that reads - * like a missing issue rather than like a caller bug. Rejecting non-positive values once, here, keeps both problems - * out of the four opaque types. + * interpolated into a request path. Unlike [[com.worxbend.codeberg4s.Owner]] a number cannot forge a path, so the + * point here is not escaping but confusion: an issue's per-repository `number` and its instance-wide `id` are both + * `Long` and are routinely mixed up, and `/issues/0` is a request Forgejo answers with a `404` that reads like a + * missing issue rather than like a caller bug. Rejecting non-positive values once, here, keeps both problems out of + * the four opaque types. */ private[issues] object NumericId: diff --git a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContext.scala b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContext.scala index db86647..029c5e9 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContext.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContext.scala @@ -9,9 +9,9 @@ import com.worxbend.codeberg4s.repositories.RepoSlug * `![](img.png)` only become useful markup when it is supplied. Without one they are rendered as literal text. * * The value travels in the JSON '''body''', never in the request path, so nothing here is defending against a forged - * path the way [[com.worxbend.codeberg4s.repositories.Owner]] does. What it does defend against is a value that would - * corrupt the request itself: a control character or a line break in a JSON string is the kind of input that turns a - * render call into a puzzle, and it is rejected at construction instead. + * path the way [[com.worxbend.codeberg4s.Owner]] does. What it does defend against is a value that would corrupt the + * request itself: a control character or a line break in a JSON string is the kind of input that turns a render call + * into a puzzle, and it is rejected at construction instead. */ opaque type MarkdownContext = String diff --git a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkupRenderRequest.scala b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkupRenderRequest.scala index 6110feb..af05931 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkupRenderRequest.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/MarkupRenderRequest.scala @@ -8,11 +8,11 @@ package com.worxbend.codeberg4s.miscellaneous * request, and a narrower request cannot be got wrong in the ways this one can. * * '''Neither [[filePath]] nor [[branchPath]] is a validated path type, and that is deliberate.''' Both travel in the - * JSON '''body''', never in the request path, so neither can forge a request the way - * [[com.worxbend.codeberg4s.repositories.Owner]] could: whatever they contain is escaped into a JSON string by the - * renderer and arrives at the instance verbatim. There is therefore nothing for a smart constructor to defend against, - * and a validator here would only refuse file names Forgejo would have accepted. [[context]] is a [[MarkdownContext]] - * because that type already exists and is already validated, not because the body needs it to be. + * JSON '''body''', never in the request path, so neither can forge a request the way [[com.worxbend.codeberg4s.Owner]] + * could: whatever they contain is escaped into a JSON string by the renderer and arrives at the instance verbatim. + * There is therefore nothing for a smart constructor to defend against, and a validator here would only refuse file + * names Forgejo would have accepted. [[context]] is a [[MarkdownContext]] because that type already exists and is + * already validated, not because the body needs it to be. * * @param text * the markup source. May be empty, which renders to an empty document rather than failing diff --git a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/TemplateName.scala b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/TemplateName.scala index 20d50eb..f350767 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/TemplateName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/miscellaneous/TemplateName.scala @@ -12,8 +12,8 @@ import com.worxbend.codeberg4s.ValidationError * * ==What is allowed, and why it is wider than every other path type here== * - * [[com.worxbend.codeberg4s.repositories.Owner]] and [[com.worxbend.codeberg4s.organizations.OrgName]] name accounts, - * whose spelling Forgejo restricts. This names a file the distribution ships, and those file names are prose: + * [[com.worxbend.codeberg4s.Owner]] and [[com.worxbend.codeberg4s.organizations.OrgName]] name accounts, whose + * spelling Forgejo restricts. This names a file the distribution ships, and those file names are prose: * `Academic Free License v3.0` is a license template, so a validator that rejected a space would refuse a name the * `/licenses` listing itself hands back. Spaces are therefore accepted and percent-encoded on the wire. * @@ -27,8 +27,7 @@ import com.worxbend.codeberg4s.ValidationError * * A blank value, a control character, and any `.` or `..` part. The last is the one that matters: `.` and `..` survive * percent-encoding untouched in some routers, so a name carrying one could walk out of the route it was meant for. - * That is a security boundary and not a convenience, exactly as [[com.worxbend.codeberg4s.repositories.Owner]]'s slash - * rule is. + * That is a security boundary and not a convenience, exactly as [[com.worxbend.codeberg4s.Owner]]'s slash rule is. */ opaque type TemplateName = String diff --git a/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala b/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala index 6ab003a..3ef5d70 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala @@ -5,12 +5,12 @@ import com.worxbend.codeberg4s.ValidationError /** The handle that names an organisation — the `{org}` of `/orgs/{org}`. * - * This is deliberately neither [[com.worxbend.codeberg4s.repositories.Owner]] nor - * [[com.worxbend.codeberg4s.users.Username]], even though all three are the same characters on the wire. An `Owner` - * answers "who does this repository belong to?" and may be a person; a `Username` answers "which account is this?" and - * is a person; an `OrgName` answers "which organisation is this?". Giving the three one type would let - * `client.organizations.members(repository.slug.owner)` compile against a personal account, which has no members and - * answers `404`. Converting is a deliberate step through [[from]], not an implicit widening. + * This is deliberately neither [[com.worxbend.codeberg4s.Owner]] nor [[com.worxbend.codeberg4s.users.Username]], even + * though all three are the same characters on the wire. An `Owner` answers "who does this repository belong to?" and + * may be a person; a `Username` answers "which account is this?" and is a person; an `OrgName` answers "which + * organisation is this?". Giving the three one type would let `client.organizations.members(repository.slug.owner)` + * compile against a personal account, which has no members and answers `404`. Converting is a deliberate step through + * [[from]], not an implicit widening. * * Values are validated as URI path segments, so an `OrgName` can be interpolated into a request path without further * escaping decisions — see [[OrgName.from]] for what that rejects and why. @@ -50,9 +50,8 @@ object OrgName: /** The name as a string, ready to be used as one path segment. * - * This is also the value to hand to [[com.worxbend.codeberg4s.repositories.Owner.from]] when an organisation's - * repositories are to be reached through `client.repos` rather than through `client.organizations`; the two types - * accept the same characters, so that conversion cannot fail in practice, but it is written out rather than - * assumed. + * This is also the value to hand to [[com.worxbend.codeberg4s.Owner.from]] when an organisation's repositories are + * to be reached through `client.repos` rather than through `client.organizations`; the two types accept the same + * characters, so that conversion cannot fail in practice, but it is written out rather than assumed. */ def value: String = name diff --git a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestHead.scala b/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestHead.scala index dc69f74..48b2b28 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestHead.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestHead.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner /** The branch a pull request is opened '''from''', in the spelling `CreatePullRequestOption.head` expects. * @@ -10,11 +10,10 @@ import com.worxbend.codeberg4s.repositories.Owner * `trim21/forgejo` against base `forgejo` in `forgejo/forgejo`. * * There is no `from(value: String)` here, and that is the point. The two spellings are produced by the two - * constructors below out of values that are '''already''' validated — an - * [[com.worxbend.codeberg4s.repositories.Owner]] cannot contain a `/` or a control character, and a - * [[com.worxbend.codeberg4s.repositories.BranchName]] cannot contain a traversal segment — so this type never has to - * parse a colon back out of a string and never has to decide what `a:b:c` meant. Both constructors are total, which is - * why neither returns an `Either`. + * constructors below out of values that are '''already''' validated — an [[com.worxbend.codeberg4s.Owner]] cannot + * contain a `/` or a control character, and a [[com.worxbend.codeberg4s.repositories.BranchName]] cannot contain a + * traversal segment — so this type never has to parse a colon back out of a string and never has to decide what + * `a:b:c` meant. Both constructors are total, which is why neither returns an `Either`. */ opaque type PullRequestHead = String diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala index 8693968..9bd4c9d 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala @@ -5,11 +5,12 @@ import com.worxbend.codeberg4s.ValidationError /** The name of a branch, as `GET /repos/{owner}/{repo}/branches/{branch}` spells it. * - * Unlike [[Owner]] and [[RepoName]], a branch name may legitimately contain `/`: - * `golden/repository/branches-list.json` captures `renovate/forgejo-github.com-go-swagger-go-swagger-cmd-swagger-0.x` - * and `v16.0/forgejo`, both from `forgejo/forgejo`. Forgejo routes those with a wildcard, so the slash has to reach - * the wire as a real separator; a name percent-encoded whole into a single segment answers `404`. [[segments]] exists - * for exactly that reason, and it is what the request builder uses. + * Unlike [[com.worxbend.codeberg4s.Owner]] and [[com.worxbend.codeberg4s.RepoName]], a branch name may legitimately + * contain `/`: `golden/repository/branches-list.json` captures + * `renovate/forgejo-github.com-go-swagger-go-swagger-cmd-swagger-0.x` and `v16.0/forgejo`, both from + * `forgejo/forgejo`. Forgejo routes those with a wildcard, so the slash has to reach the wire as a real separator; a + * name percent-encoded whole into a single segment answers `404`. [[segments]] exists for exactly that reason, and it + * is what the request builder uses. * * That makes the validation below a security boundary rather than a formality: a name is decomposed into segments * here, and a `.` or `..` segment — the one thing that would let a caller climb out of the branch route — is rejected diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoSlug.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoSlug.scala index c0f145a..fb10f7c 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoSlug.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/RepoSlug.scala @@ -1,5 +1,8 @@ package com.worxbend.codeberg4s.repositories +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName + /** The `owner/name` pair that identifies a repository. * * A composite identifier deserves a real type: two loose strings let a caller swap them silently, and every repository diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/CreateRepository.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/CreateRepository.scala index c85a915..3a1c154 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/CreateRepository.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/CreateRepository.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName /** Everything `POST /user/repos` may be told, as one value. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/EditRepository.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/EditRepository.scala index dcc80d2..b69a082 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/EditRepository.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/EditRepository.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName /** Where a repository's issues live when they are not in Forgejo — the `external_tracker` object of `EditRepoOption`. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepository.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepository.scala index 17ffa2a..d62501e 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepository.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepository.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.organizations.TeamId -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName /** Everything `POST /repos/migrate` may be told, as one value. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/CreateFork.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/CreateFork.scala index 6e6b5cd..2b96a33 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/CreateFork.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/CreateFork.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.repositories.publishing -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName /** Everything `POST /repos/{owner}/{repo}/forks` may be told, as one value. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/GenerateRepository.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/GenerateRepository.scala index 922a52d..d66e9c8 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/GenerateRepository.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/GenerateRepository.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.repositories.publishing +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName /** Everything `POST /repos/{template_owner}/{template_repo}/generate` may be told, as one value. * diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala index e86a7a3..ebbf167 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala @@ -6,8 +6,8 @@ import com.worxbend.codeberg4s.ValidationError /** One repository topic — `forge`, `forgejo`, `git`, `self-hosted` on `golden/repository/topics.json`. * * A topic is put in a request path by `PUT` and `DELETE /repos/{owner}/{repo}/topics/{topic}`, so a raw `String` would - * be a path-forging hazard exactly as it is for [[com.worxbend.codeberg4s.repositories.Owner]]. That, and only that, - * is what this type guarantees: the value is one safe URI path segment. + * be a path-forging hazard exactly as it is for [[com.worxbend.codeberg4s.Owner]]. That, and only that, is what this + * type guarantees: the value is one safe URI path segment. * * '''It deliberately does not encode Forgejo's own topic grammar.''' The pinned spec declares no `pattern` and no * `maxLength` for a topic name anywhere — neither on `RepoTopicOptions.topics` nor on the `topic` path parameter — so diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/User.scala b/modules/domain/src/com/worxbend/codeberg4s/users/User.scala index 81980f7..cb33315 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/User.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/User.scala @@ -20,7 +20,7 @@ import java.time.Instant * @param id * the instance-local numeric identifier * @param login - * the handle in URLs — the value that becomes a [[com.worxbend.codeberg4s.repositories.Owner]] + * the handle in URLs — the value that becomes a [[com.worxbend.codeberg4s.Owner]] * @param fullName * the display name, absent when the account left it blank * @param email diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala b/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala index d881814..3db01bd 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala @@ -5,12 +5,12 @@ import com.worxbend.codeberg4s.ValidationError /** The handle that names a person — the `{username}` of `/users/{username}`. * - * This is deliberately '''not''' [[com.worxbend.codeberg4s.repositories.Owner]], even though the two are the same - * characters on the wire. An `Owner` answers "who does this repository belong to?", and the answer may be an - * organisation; a `Username` answers "which account is this?", and the endpoints that take one — - * `/users/{u}/followers`, `/users/{u}/keys` — are about a person. Giving them one type would let - * `client.users.keys(repository.slug.owner)` compile against an organisation that has no keys, which is exactly the - * confusion the type is here to prevent. Converting is a deliberate step through [[from]], not an implicit widening. + * This is deliberately '''not''' [[com.worxbend.codeberg4s.Owner]], even though the two are the same characters on the + * wire. An `Owner` answers "who does this repository belong to?", and the answer may be an organisation; a `Username` + * answers "which account is this?", and the endpoints that take one — `/users/{u}/followers`, `/users/{u}/keys` — are + * about a person. Giving them one type would let `client.users.keys(repository.slug.owner)` compile against an + * organisation that has no keys, which is exactly the confusion the type is here to prevent. Converting is a + * deliberate step through [[from]], not an implicit widening. * * Values are validated as URI path segments, so a `Username` can be interpolated into a request path without further * escaping decisions — see [[Username.from]] for what that rejects and why. diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/account/CreateRepository.scala b/modules/domain/src/com/worxbend/codeberg4s/users/account/CreateRepository.scala index 9866a96..f97ec6b 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/account/CreateRepository.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/account/CreateRepository.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.users.account +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName /** What `POST /user/repos` is told. * @@ -33,7 +33,7 @@ import com.worxbend.codeberg4s.repositories.RepoName * one a caller could not ask for. A name the instance does not know arrives as a `422`. * * @param name - * the repository's name, already validated as a path segment by [[com.worxbend.codeberg4s.repositories.RepoName]] + * the repository's name, already validated as a path segment by [[com.worxbend.codeberg4s.RepoName]] * @param description * the one-line description, absent to leave it empty * @param isPrivate @@ -115,9 +115,9 @@ object CreateRepository: /** Starts a command from the one field Forgejo requires. * - * Total rather than validated: [[com.worxbend.codeberg4s.repositories.RepoName]] has already refused everything that - * cannot be a path segment, and whether the name is free is the instance's judgement — it arrives as a `409`, which - * is the one status this endpoint declares that no other creation route in the library does. + * Total rather than validated: [[com.worxbend.codeberg4s.RepoName]] has already refused everything that cannot be a + * path segment, and whether the name is free is the instance's judgement — it arrives as a `409`, which is the one + * status this endpoint declares that no other creation route in the library does. */ def named(name: RepoName): CreateRepository = CreateRepository( diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/IdentifierProps.scala b/modules/domain/test/src/com/worxbend/codeberg4s/IdentifierProps.scala index e18e01a..463dffa 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/IdentifierProps.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/IdentifierProps.scala @@ -17,9 +17,7 @@ import com.worxbend.codeberg4s.pulls.ReviewId import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner import com.worxbend.codeberg4s.repositories.ReleaseId -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.TagName import com.worxbend.codeberg4s.users.Username diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/OwnerSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/OwnerSuite.scala similarity index 91% rename from modules/domain/test/src/com/worxbend/codeberg4s/repositories/OwnerSuite.scala rename to modules/domain/test/src/com/worxbend/codeberg4s/OwnerSuite.scala index f8c3e60..779c5e3 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/OwnerSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/OwnerSuite.scala @@ -1,6 +1,4 @@ -package com.worxbend.codeberg4s.repositories - -import com.worxbend.codeberg4s.ValidationError +package com.worxbend.codeberg4s import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoNameSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/RepoNameSuite.scala similarity index 91% rename from modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoNameSuite.scala rename to modules/domain/test/src/com/worxbend/codeberg4s/RepoNameSuite.scala index 27a4a87..22592eb 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoNameSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/RepoNameSuite.scala @@ -1,6 +1,4 @@ -package com.worxbend.codeberg4s.repositories - -import com.worxbend.codeberg4s.ValidationError +package com.worxbend.codeberg4s import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/issues/FilterTokenSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/issues/FilterTokenSuite.scala index 4b42190..50ded1b 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/issues/FilterTokenSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/issues/FilterTokenSuite.scala @@ -6,7 +6,7 @@ import munit.FunSuite * * The comma is the point. Forgejo joins several label names into one `labels` parameter with no escape, so a name * carrying a comma would silently become two filters — the query-string equivalent of the path forging - * [[com.worxbend.codeberg4s.repositories.Owner]] rejects. + * [[com.worxbend.codeberg4s.Owner]] rejects. */ final class FilterTokenSuite extends FunSuite: diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueSearchQuerySuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueSearchQuerySuite.scala index fc9a315..76d060b 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueSearchQuerySuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueSearchQuerySuite.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContextSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContextSuite.scala index 9114079..c51ba18 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContextSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/miscellaneous/MarkdownContextSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.miscellaneous +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestIdentifiersSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestIdentifiersSuite.scala index cd14d1a..0458893 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestIdentifiersSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestIdentifiersSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestQuerySuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestQuerySuite.scala index 6c48585..6c3c205 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestQuerySuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/pulls/PullRequestQuerySuite.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.LabelId import com.worxbend.codeberg4s.issues.MilestoneId import com.worxbend.codeberg4s.issues.StateFilter import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoSlugSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoSlugSuite.scala index cd59115..733d4c8 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoSlugSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/RepoSlugSuite.scala @@ -1,5 +1,8 @@ package com.worxbend.codeberg4s.repositories +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName + import munit.FunSuite final class RepoSlugSuite extends FunSuite: diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/AdminCommandsSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/AdminCommandsSuite.scala index 0bd09c6..00c3bee 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/AdminCommandsSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/AdminCommandsSuite.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.organizations.TeamId import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/CreateRepositoryBuilderSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/CreateRepositoryBuilderSuite.scala index b4ce8f3..998d27e 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/CreateRepositoryBuilderSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/CreateRepositoryBuilderSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/EditRepositoryBuilderSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/EditRepositoryBuilderSuite.scala index 765fd4d..45c6321 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/EditRepositoryBuilderSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/EditRepositoryBuilderSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepositoryBuilderSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepositoryBuilderSuite.scala index abbfba7..ccc9a3d 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepositoryBuilderSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/MigrateRepositoryBuilderSuite.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.repositories.admin +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.organizations.TeamId -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/RemoteCredentialSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/RemoteCredentialSuite.scala index 3b5551f..dbf105f 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/RemoteCredentialSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/admin/RemoteCredentialSuite.scala @@ -4,8 +4,8 @@ import com.worxbend.codeberg4s.CallContext import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/publishing/PublishingCommandsSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/publishing/PublishingCommandsSuite.scala index 3925b11..5847738 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/repositories/publishing/PublishingCommandsSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/repositories/publishing/PublishingCommandsSuite.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.repositories.publishing +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.TagName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/users/account/AccountCommandSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/users/account/AccountCommandSuite.scala index d0b8855..ca70cb6 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/users/account/AccountCommandSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/users/account/AccountCommandSuite.scala @@ -1,8 +1,8 @@ package com.worxbend.codeberg4s.users.account +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.RepoName import munit.FunSuite diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/users/social/SocialDomainSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/users/social/SocialDomainSuite.scala index 7586464..849c8e5 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/users/social/SocialDomainSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/users/social/SocialDomainSuite.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.users.social +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.ApiToken -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.RepoSlug import munit.FunSuite diff --git a/modules/examples/src/com/worxbend/codeberg4s/examples/CreatingAnIssue.scala b/modules/examples/src/com/worxbend/codeberg4s/examples/CreatingAnIssue.scala index dcf8810..f6da880 100644 --- a/modules/examples/src/com/worxbend/codeberg4s/examples/CreatingAnIssue.scala +++ b/modules/examples/src/com/worxbend/codeberg4s/examples/CreatingAnIssue.scala @@ -4,13 +4,13 @@ import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.ApiToken import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.issues.CreateIssue import com.worxbend.codeberg4s.issues.Issue -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Await import scala.concurrent.ExecutionContext diff --git a/modules/examples/src/com/worxbend/codeberg4s/examples/HandlingErrors.scala b/modules/examples/src/com/worxbend/codeberg4s/examples/HandlingErrors.scala index 7832aff..172e53b 100644 --- a/modules/examples/src/com/worxbend/codeberg4s/examples/HandlingErrors.scala +++ b/modules/examples/src/com/worxbend/codeberg4s/examples/HandlingErrors.scala @@ -4,10 +4,10 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Await diff --git a/modules/examples/src/com/worxbend/codeberg4s/examples/HelloCodeberg.scala b/modules/examples/src/com/worxbend/codeberg4s/examples/HelloCodeberg.scala index 83273be..7771ac1 100644 --- a/modules/examples/src/com/worxbend/codeberg4s/examples/HelloCodeberg.scala +++ b/modules/examples/src/com/worxbend/codeberg4s/examples/HelloCodeberg.scala @@ -2,11 +2,11 @@ package com.worxbend.codeberg4s.examples import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ServerVersion import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Await diff --git a/modules/examples/src/com/worxbend/codeberg4s/examples/ObservingRequests.scala b/modules/examples/src/com/worxbend/codeberg4s/examples/ObservingRequests.scala index 6256759..321c380 100644 --- a/modules/examples/src/com/worxbend/codeberg4s/examples/ObservingRequests.scala +++ b/modules/examples/src/com/worxbend/codeberg4s/examples/ObservingRequests.scala @@ -4,11 +4,11 @@ import com.worxbend.codeberg4s.CallContext import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.CodebergError +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Await import scala.concurrent.ExecutionContext diff --git a/modules/examples/src/com/worxbend/codeberg4s/examples/WalkingPages.scala b/modules/examples/src/com/worxbend/codeberg4s/examples/WalkingPages.scala index be6c3c2..6aec874 100644 --- a/modules/examples/src/com/worxbend/codeberg4s/examples/WalkingPages.scala +++ b/modules/examples/src/com/worxbend/codeberg4s/examples/WalkingPages.scala @@ -2,6 +2,8 @@ package com.worxbend.codeberg4s.examples import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.issues.Issue @@ -10,8 +12,6 @@ import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.Await import scala.concurrent.ExecutionContext diff --git a/modules/it/src/com/worxbend/codeberg4s/it/ForgejoBootstrap.scala b/modules/it/src/com/worxbend/codeberg4s/it/ForgejoBootstrap.scala index 96ac443..e437013 100644 --- a/modules/it/src/com/worxbend/codeberg4s/it/ForgejoBootstrap.scala +++ b/modules/it/src/com/worxbend/codeberg4s/it/ForgejoBootstrap.scala @@ -42,7 +42,7 @@ object ForgejoBootstrap: * to a real instance — the live suite is read-only and anonymous unless the operator supplies their own token. * * @param username - * the login, which is also the [[com.worxbend.codeberg4s.repositories.Owner]] of everything the suite creates + * the login, which is also the [[com.worxbend.codeberg4s.Owner]] of everything the suite creates * @param password * the password used for the one basic-authenticated call that issues a token * @param email diff --git a/modules/it/test/src/com/worxbend/codeberg4s/it/CodebergLiveSmokeSuite.scala b/modules/it/test/src/com/worxbend/codeberg4s/it/CodebergLiveSmokeSuite.scala index 9b08922..971b172 100644 --- a/modules/it/test/src/com/worxbend/codeberg4s/it/CodebergLiveSmokeSuite.scala +++ b/modules/it/test/src/com/worxbend/codeberg4s/it/CodebergLiveSmokeSuite.scala @@ -2,13 +2,13 @@ package com.worxbend.codeberg4s.it import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future diff --git a/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoContainerSuite.scala b/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoContainerSuite.scala index 304b7a7..640ac67 100644 --- a/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoContainerSuite.scala +++ b/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoContainerSuite.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.it import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.CreateIssue import com.worxbend.codeberg4s.issues.Issue @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.issues.LifecycleState import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.RepoName import com.dimafeng.testcontainers.GenericContainer import com.dimafeng.testcontainers.munit.TestContainerForAll diff --git a/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoInstance.scala b/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoInstance.scala index a127ac0..abd577f 100644 --- a/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoInstance.scala +++ b/modules/it/test/src/com/worxbend/codeberg4s/it/ForgejoInstance.scala @@ -2,9 +2,9 @@ package com.worxbend.codeberg4s.it import com.worxbend.codeberg4s.BaseUri import com.worxbend.codeberg4s.CodebergClient +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName import com.worxbend.codeberg4s.retry.RetryPolicy import com.dimafeng.testcontainers.GenericContainer diff --git a/site/src/guides/01-getting-started.md b/site/src/guides/01-getting-started.md index 7498fe5..c0e8907 100644 --- a/site/src/guides/01-getting-started.md +++ b/site/src/guides/01-getting-started.md @@ -89,8 +89,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergConfig import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.Await import scala.concurrent.ExecutionContext @@ -167,8 +167,8 @@ is how a test substitutes a stub. ```scala mdoc:compile-only import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName val target: Either[ValidationError, (Owner, RepoName)] = for @@ -193,8 +193,8 @@ defines the term. ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future diff --git a/site/src/guides/03-errors.md b/site/src/guides/03-errors.md index 0c4f66a..0d56ee2 100644 --- a/site/src/guides/03-errors.md +++ b/site/src/guides/03-errors.md @@ -59,8 +59,8 @@ carrying the whole `CodebergError`: ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Future @@ -75,8 +75,8 @@ def viaConvenience(client: CodebergClient, owner: Owner, name: RepoName): Future ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Future @@ -118,8 +118,8 @@ case class, so it pattern-matches directly: import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.ExecutionContext @@ -142,8 +142,8 @@ On the typed rail, as a `Left`: ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.ExecutionContext @@ -262,8 +262,8 @@ and anything else propagates. import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.ExecutionContext @@ -318,8 +318,8 @@ chooses how long that string is. ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.ExecutionContext diff --git a/site/src/guides/04-pagination.md b/site/src/guides/04-pagination.md index 421eadc..565ef3a 100644 --- a/site/src/guides/04-pagination.md +++ b/site/src/guides/04-pagination.md @@ -15,8 +15,8 @@ import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.Future @@ -112,8 +112,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -170,8 +170,8 @@ import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -202,8 +202,8 @@ Counting open issues without keeping any of them: import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -262,8 +262,8 @@ import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageWalk -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -295,8 +295,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageWalk -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future diff --git a/site/src/guides/07-testing-your-code.md b/site/src/guides/07-testing-your-code.md index 74b66cc..76cce4d 100644 --- a/site/src/guides/07-testing-your-code.md +++ b/site/src/guides/07-testing-your-code.md @@ -51,8 +51,8 @@ Take the client as a parameter rather than constructing one: ```scala mdoc:compile-only import com.worxbend.codeberg4s.CodebergClient -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -120,8 +120,8 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.retry.RetryPolicy import sttp.client4.Backend diff --git a/site/src/guides/08-writing-data.md b/site/src/guides/08-writing-data.md index 87f2261..6cabd5c 100644 --- a/site/src/guides/08-writing-data.md +++ b/site/src/guides/08-writing-data.md @@ -19,8 +19,8 @@ import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.issues.CreateIssue import com.worxbend.codeberg4s.issues.Issue -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.Future @@ -63,8 +63,8 @@ import com.worxbend.codeberg4s.pulls.CreatePullRequest import com.worxbend.codeberg4s.pulls.PullRequest import com.worxbend.codeberg4s.pulls.PullRequestHead import com.worxbend.codeberg4s.repositories.BranchName -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.Future @@ -92,8 +92,8 @@ import com.worxbend.codeberg4s.pulls.MergePullRequest import com.worxbend.codeberg4s.pulls.MergeStyle import com.worxbend.codeberg4s.pulls.PullRequestNumber import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.Future @@ -228,8 +228,8 @@ between. import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.repositories.ContentEntry import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.RepositoryContent import com.worxbend.codeberg4s.repositories.admin.CommitOptions import com.worxbend.codeberg4s.repositories.admin.FileBytes @@ -310,8 +310,8 @@ whole or does not land at all: import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.repositories.admin.ChangeFiles import com.worxbend.codeberg4s.repositories.admin.CommitOptions import com.worxbend.codeberg4s.repositories.admin.FileBytes diff --git a/site/src/guides/10-troubleshooting.md b/site/src/guides/10-troubleshooting.md index d792c4b..94ffb10 100644 --- a/site/src/guides/10-troubleshooting.md +++ b/site/src/guides/10-troubleshooting.md @@ -28,8 +28,8 @@ and the elapsed time: import com.worxbend.codeberg4s.CodebergClient import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.Owner -import com.worxbend.codeberg4s.repositories.RepoName +import com.worxbend.codeberg4s.Owner +import com.worxbend.codeberg4s.RepoName import scala.concurrent.ExecutionContext import scala.concurrent.Future From 31f46e26e6c32c8e97670978ba8542fcc58d771e Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 12:11:39 +0300 Subject: [PATCH 08/31] feat(model): re-export the everyday surface from the package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-time caller had to know the library's internal package layout before writing anything: Auth lives in the auth sub-package, Page, PageParams, and PageSize in paging, while the client, the config, and the identifier types sit at the root. The README quick start alone needed six import lines. This adds a small, curated export file in the domain module that re-exports Auth, Page, PageParams, and PageSize into the root package com.worxbend.codeberg4s. With Owner and RepoName now living at the root too, a single 'import com.worxbend.codeberg4s.*' covers the whole quick-start flow: build an Auth, construct a client, call an endpoint, page through a listing. The list is deliberately short and names types one at a time — no whole-package re-exports — so every other type keeps exactly one canonical import from its own sub-package. The README quick start now uses the wildcard import and explains why it is sufficient. --- README.md | 14 ++++++++------ .../src/com/worxbend/codeberg4s/exports.scala | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 modules/domain/src/com/worxbend/codeberg4s/exports.scala diff --git a/README.md b/README.md index dc9bbc3..9eeb659 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,7 @@ write code that *handles* a `CodebergError` without linking a HTTP client. ## Quick start ```scala -import com.worxbend.codeberg4s.CodebergClient -import com.worxbend.codeberg4s.CodebergConfig -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.Owner -import com.worxbend.codeberg4s.RepoName +import com.worxbend.codeberg4s.* import scala.concurrent.ExecutionContext import scala.concurrent.Future @@ -90,6 +85,13 @@ val stars: Either[ValidationError, Future[Long]] = client.close() ``` +The single wildcard import works because the root package re-exports the +everyday surface — `Auth`, `Page`, `PageParams`, and `PageSize` — next to the +types that already live there (`CodebergClient`, `CodebergConfig`, `Owner`, +`RepoName`, `ValidationError`, …). The re-export list is deliberately short: +more specialised types keep one canonical import from their own sub-package, +as the examples below show. + Authenticating is a different `Auth` and nothing else. A token is validated on the way in, so a blank or control-character-bearing string never reaches a request header: diff --git a/modules/domain/src/com/worxbend/codeberg4s/exports.scala b/modules/domain/src/com/worxbend/codeberg4s/exports.scala new file mode 100644 index 0000000..dd80bcd --- /dev/null +++ b/modules/domain/src/com/worxbend/codeberg4s/exports.scala @@ -0,0 +1,17 @@ +package com.worxbend.codeberg4s + +/* Root-level re-exports of the everyday surface. + * + * A caller writing the quick-start flow — build an `Auth`, construct a client, page through a + * listing — should not need to know which sub-package each of those types lives in. These exports + * make `import com.worxbend.codeberg4s.*` sufficient for that flow. + * + * The list is deliberately short and curated: only types that appear in almost every program are + * re-exported, and never a whole package. Everything else keeps a single canonical import from its + * own sub-package, so a name in an error message or a stack trace still points at one place. + */ + +export com.worxbend.codeberg4s.auth.Auth +export com.worxbend.codeberg4s.paging.Page +export com.worxbend.codeberg4s.paging.PageParams +export com.worxbend.codeberg4s.paging.PageSize From 2b078fad1700a099e6c7b3ad6c964dcf0225825a Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 16:56:07 +0300 Subject: [PATCH 09/31] feat(model): add compile-time literal constructors for path identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nearly every identifier this library takes is written down by the programmer as a string literal: Owner("forgejo"), BranchName("main"), RepoName("codeberg4s"). Until now the only way in was `from`, which returns Either[ValidationError, A] because a value computed at run time really can be invalid. A literal cannot: it is either valid or it is not, and which one it is was already decidable while the code compiled. The result was that every literal dragged a for-comprehension or an `orFail` helper behind it purely to discharge a failure that could not happen. Each of the eighteen identifiers that validate as URI path segments now also has an `apply` taking a string literal and returning the identifier itself, no Either around it. An invalid literal is a compile error naming the field and pointing at the literal. `from` is unchanged and remains the way in for a value known only at run time — handing one to the new constructor is itself a compile error, with a message saying so. The check is a single `inline if` over scala.compiletime.ops.string .Matches, which asks the compiler whether a literal type matches a regular expression. There is no macro, and the call folds away to the literal, so nothing of this reaches the bytecode. That does mean the rule is now written twice: once as PathSegment's readable if/else chain, once as a regular expression in the new SegmentLiteral object. Calling PathSegment at compile time would need a macro, and a macro would need its own compilation unit; the duplication is the cheaper of the two. SegmentLiteralSuite pins the spellings together by walking a corpus of awkward values through both and demanding the same verdict, so a change to one that is not mirrored in the other fails the build. One deliberate difference: the literal check refuses surrounding whitespace where `from` trims it. Silently rewriting what someone typed is worse than telling them about the typo they can simply fix. SegmentLiteral is public only because an inline method is expanded in the caller's own code and everything it mentions has to be reachable from there. Its Scaladoc says so; nobody should call it directly. --- .../src/com/worxbend/codeberg4s/Owner.scala | 13 +++ .../com/worxbend/codeberg4s/RepoName.scala | 9 ++ .../worxbend/codeberg4s/SegmentLiteral.scala | 91 ++++++++++++++++++ .../codeberg4s/organizations/OrgName.scala | 11 +++ .../codeberg4s/repositories/BranchName.scala | 11 +++ .../codeberg4s/repositories/ContentPath.scala | 11 +++ .../codeberg4s/repositories/TagName.scala | 11 +++ .../repositories/access/AccessNames.scala | 21 ++++ .../repositories/actions/ActionNames.scala | 41 ++++++++ .../repositories/admin/AdminIds.scala | 11 +++ .../repositories/gitdata/RefName.scala | 11 +++ .../repositories/hooks/HookIds.scala | 11 +++ .../repositories/hooks/RepositoryFlag.scala | 11 +++ .../repositories/hooks/WikiPageName.scala | 11 +++ .../repositories/publishing/Topic.scala | 11 +++ .../worxbend/codeberg4s/users/Username.scala | 11 +++ .../codeberg4s/users/social/AccessToken.scala | 11 +++ .../codeberg4s/SegmentLiteralSuite.scala | 96 +++++++++++++++++++ 18 files changed, 403 insertions(+) create mode 100644 modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala create mode 100644 modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala diff --git a/modules/domain/src/com/worxbend/codeberg4s/Owner.scala b/modules/domain/src/com/worxbend/codeberg4s/Owner.scala index e4aa0f6..3b6841d 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/Owner.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/Owner.scala @@ -20,6 +20,19 @@ object Owner: def from(value: String): Either[ValidationError, Owner] = PathSegment.from("owner", value) + /** Builds an owner from a string literal, checked while the code compiles. + * + * `Owner("forgejo")` '''is''' the owner — there is no `Either` to unwrap, because a literal is either valid or it is + * not, and which one it is can be decided before the program runs. An invalid literal is a compile error pointing at + * the literal itself. The rules are [[from]]'s, minus the trim: surrounding whitespace is refused rather than + * removed, so nothing silently rewrites what was written. See [[SegmentLiteral]]. + * + * Use [[from]] for a value known only at run time — an argument, a config entry, a decoded field. Handing one to + * this constructor is itself a compile error. + */ + inline def apply[V <: String & Singleton](inline value: V): Owner = + SegmentLiteral.plain("owner", value) + extension (owner: Owner) /** The owner as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala b/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala index cbf75ce..8ee7822 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/RepoName.scala @@ -19,6 +19,15 @@ object RepoName: def from(value: String): Either[ValidationError, RepoName] = PathSegment.from("repoName", value) + /** Builds a repository name from a string literal, checked while the code compiles. + * + * `RepoName("forgejo")` '''is''' the name, with no `Either` to unwrap: an invalid literal is a compile error + * pointing at the literal itself. The rules are [[from]]'s, minus the trim. See [[SegmentLiteral]], and use [[from]] + * for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): RepoName = + SegmentLiteral.plain("repoName", value) + extension (name: RepoName) /** The repository name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala b/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala new file mode 100644 index 0000000..6d0546e --- /dev/null +++ b/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala @@ -0,0 +1,91 @@ +package com.worxbend.codeberg4s + +import scala.compiletime.codeOf +import scala.compiletime.constValue +import scala.compiletime.constValueOpt +import scala.compiletime.error +import scala.compiletime.ops.string.Matches + +/** The compile-time half of [[PathSegment]] — the same rules, checked while the code compiles. + * + * ==Why this exists== + * + * Almost every identifier in this library is written down as a string literal by the programmer, not computed at run + * time: `Owner("forgejo")`, `BranchName("main")`, `RepoName("codeberg4s")`. A literal is either valid or it is not, and + * which one it is can be decided before the program ever runs. Yet `Owner.from` returns `Either[ValidationError, + * Owner]`, so every one of those literals used to drag a `for` comprehension or an `orFail` helper behind it purely to + * discharge a failure that cannot happen. + * + * The `apply` on each identifier's companion — `Owner("forgejo")` — is that same check moved to compile time. It takes + * the value, returns the identifier with no `Either` around it, and if the literal is invalid the '''compiler''' says + * so, at the call site, pointing at the offending literal. `from` remains the way in for a value that is only known at + * run time: a command-line argument, a config file, a field of a decoded response. + * + * ==How it works, and what it costs== + * + * There is no macro here. Each check is a single `inline if` over + * [[scala.compiletime.ops.string.Matches]], which asks the compiler whether a literal '''type''' matches a regular + * expression. That happens during typing, so the whole call folds away to the literal string; nothing of this object + * survives into the bytecode of the call site. + * + * The price is that the rule is written twice — once as the readable `if`/`else` chain in [[PathSegment]], and once as + * a regular expression here — which is exactly the duplication [[PathSegment]]'s own Scaladoc warns about. It is + * accepted for one reason only: `PathSegment.from` cannot be called at compile time without a macro, and a macro would + * need its own compilation unit. `SegmentLiteralSuite` pins the two spellings together by checking the same values + * through both, so a change to one that is not mirrored in the other fails the build rather than drifting quietly. + * + * ==Why it is public== + * + * It is not part of the API anyone should call. It has to be public because an `inline def` is expanded at the call + * site, in the caller's own code, so everything it mentions has to be reachable from there. Call the identifier + * companions — `Owner("forgejo")` — never this. + */ +object SegmentLiteral: + + /** The regular expression form of [[PathSegment.from]]'s rules. + * + * Reading it clause by clause: `(?!\.\.?$)` refuses the traversal segments `.` and `..`; the alternation that + * follows demands at least one character, forbids `/` and any control character throughout, and forbids whitespace + * at either end. That last clause is where the literal check is deliberately '''stricter''' than + * [[PathSegment.from]], which trims: `Owner(" forgejo ")` is refused rather than silently accepted as `"forgejo"`, + * because a literal with stray whitespace in it is a typo the programmer can simply fix, and quietly changing what + * someone wrote is worse than telling them. + */ + type Plain = "(?!\\.\\.?$)(?:[^/\\s\\p{Cntrl}]|[^/\\s\\p{Cntrl}][^/\\p{Cntrl}]*[^/\\s\\p{Cntrl}])" + + /** The regular expression form of [[PathSegment.segmented]]'s rules. + * + * Same shape as [[Plain]], with `/` allowed as a separator and four leading lookaheads guarding it. In order, they + * refuse: a `.` or `..` anywhere among the segments; a leading slash; a doubled slash, which is the empty segment in + * the middle of `a//b`; and a trailing slash. (They are written as lookaheads rather than spelled into the body + * because a regular expression that describes "no segment is `..`" positionally is unreadable.) + */ + type Segmented = + "(?!.*(?:^|/)\\.\\.?(?:/|$))(?!/)(?!.*//)(?!.*/$)(?:[^\\s\\p{Cntrl}]|[^\\s\\p{Cntrl}][^\\p{Cntrl}]*[^\\s\\p{Cntrl}])" + + /** Accepts `value` if it can stand alone as one path segment, and fails the compilation if it cannot. + * + * @param field + * the field name to name in the compile error, matching the one [[PathSegment.from]] reports at run time + */ + inline def plain[V <: String & Singleton](inline field: String, inline value: V): String = + inline constValueOpt[V] match + case Some(_) => + inline if constValue[Matches[V, Plain]] then value + else error("not a valid " + field + ": " + codeOf(value)) + case None => + error("a " + field + " built this way has to be a string literal; use `.from` for a run-time value") + + /** Accepts `value` if every `/`-separated part of it can stand alone as one path segment, and fails the compilation + * if any part cannot. + * + * @param field + * the field name to name in the compile error, matching the one [[PathSegment.segmented]] reports at run time + */ + inline def segmented[V <: String & Singleton](inline field: String, inline value: V): String = + inline constValueOpt[V] match + case Some(_) => + inline if constValue[Matches[V, Segmented]] then value + else error("not a valid " + field + ": " + codeOf(value)) + case None => + error("a " + field + " built this way has to be a string literal; use `.from` for a run-time value") diff --git a/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala b/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala index 3ef5d70..6c05685 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The handle that names an organisation — the `{org}` of `/orgs/{org}`. @@ -46,6 +47,16 @@ object OrgName: def from(value: String): Either[ValidationError, OrgName] = PathSegment.from(Field, value) + /** Builds an organisation name from a string literal, checked while the code compiles. + * + * `OrgName("...")` '''is''' the organisation name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): OrgName = + SegmentLiteral.plain("orgName", value) + extension (name: OrgName) /** The name as a string, ready to be used as one path segment. diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala index 9bd4c9d..2895027 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The name of a branch, as `GET /repos/{owner}/{repo}/branches/{branch}` spells it. @@ -31,6 +32,16 @@ object BranchName: def from(value: String): Either[ValidationError, BranchName] = PathSegment.segmented("branch", value) + /** Builds a branch name from a string literal, checked while the code compiles. + * + * `BranchName("...")` '''is''' the branch name, with no `Either` to unwrap: a literal is either valid or it is not, + * and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): BranchName = + SegmentLiteral.segmented("branch", value) + extension (branch: BranchName) /** The name as Forgejo spells it, slashes included. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/ContentPath.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/ContentPath.scala index c54e53e..0aee619 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/ContentPath.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/ContentPath.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** A path to a file or directory inside a repository, as `GET /repos/{owner}/{repo}/contents/{filepath}` spells it. @@ -26,6 +27,16 @@ object ContentPath: def from(value: String): Either[ValidationError, ContentPath] = PathSegment.segmented("filepath", value) + /** Builds a repository-relative path from a string literal, checked while the code compiles. + * + * `ContentPath("...")` '''is''' the repository-relative path, with no `Either` to unwrap: a literal is either valid + * or it is not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, + * minus the trim — surrounding whitespace is refused rather than removed. See + * [[com.worxbend.codeberg4s.SegmentLiteral]], and use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): ContentPath = + SegmentLiteral.segmented("filepath", value) + extension (path: ContentPath) /** The path as Forgejo spells it, slashes included. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/TagName.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/TagName.scala index cdbeea9..dab1d0c 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/TagName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/TagName.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The name of a Git tag — `v16.0.2` on `golden/repository/tags-list.json`. @@ -24,6 +25,16 @@ object TagName: def from(value: String): Either[ValidationError, TagName] = PathSegment.segmented("tag", value) + /** Builds a tag name from a string literal, checked while the code compiles. + * + * `TagName("...")` '''is''' the tag name, with no `Either` to unwrap: a literal is either valid or it is not, and an + * invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): TagName = + SegmentLiteral.segmented("tag", value) + extension (tag: TagName) /** The name as Forgejo spells it. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessNames.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessNames.scala index 25b8f82..a7820de 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessNames.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessNames.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.access import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The name that addresses one branch protection rule — the `{name}` of @@ -54,6 +55,16 @@ object BranchRuleName: def from(value: String): Either[ValidationError, BranchRuleName] = PathSegment.from("branchRuleName", value) + /** Builds a rule name from a string literal, checked while the code compiles. + * + * `BranchRuleName("...")` '''is''' the rule name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): BranchRuleName = + SegmentLiteral.plain("branchRuleName", value) + extension (name: BranchRuleName) /** The name as a string, ready to be used as one path segment. */ @@ -123,6 +134,16 @@ object TeamName: def from(value: String): Either[ValidationError, TeamName] = PathSegment.from("teamName", value) + /** Builds a team name from a string literal, checked while the code compiles. + * + * `TeamName("...")` '''is''' the team name, with no `Either` to unwrap: a literal is either valid or it is not, and + * an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): TeamName = + SegmentLiteral.plain("teamName", value) + extension (name: TeamName) /** The name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionNames.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionNames.scala index 903bdbb..de596bd 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionNames.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionNames.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The identifier of a registered runner, as the runner endpoints take it in a path. @@ -27,6 +28,16 @@ object RunnerId: def from(value: String): Either[ValidationError, RunnerId] = PathSegment.from("runnerId", value) + /** Builds a runner identifier from a string literal, checked while the code compiles. + * + * `RunnerId("...")` '''is''' the runner identifier, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): RunnerId = + SegmentLiteral.plain("runnerId", value) + /** The identifier of the runner whose numeric `id` is `value`. * * Total rather than validated: a `Long` renders to digits, which are always a legal path segment. @@ -66,6 +77,16 @@ object SecretName: def from(value: String): Either[ValidationError, SecretName] = PathSegment.from("secretName", value) + /** Builds a secret name from a string literal, checked while the code compiles. + * + * `SecretName("...")` '''is''' the secret name, with no `Either` to unwrap: a literal is either valid or it is not, + * and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): SecretName = + SegmentLiteral.plain("secretName", value) + extension (name: SecretName) /** The name as a string, ready to be used as one path segment. */ @@ -92,6 +113,16 @@ object VariableName: def from(value: String): Either[ValidationError, VariableName] = PathSegment.from("variableName", value) + /** Builds a variable name from a string literal, checked while the code compiles. + * + * `VariableName("...")` '''is''' the variable name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): VariableName = + SegmentLiteral.plain("variableName", value) + extension (name: VariableName) /** The name as a string, ready to be used as one path segment. */ @@ -119,6 +150,16 @@ object WorkflowFileName: def from(value: String): Either[ValidationError, WorkflowFileName] = PathSegment.from("workflowFileName", value) + /** Builds a workflow file name from a string literal, checked while the code compiles. + * + * `WorkflowFileName("...")` '''is''' the workflow file name, with no `Either` to unwrap: a literal is either valid + * or it is not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, + * minus the trim — surrounding whitespace is refused rather than removed. See + * [[com.worxbend.codeberg4s.SegmentLiteral]], and use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): WorkflowFileName = + SegmentLiteral.plain("workflowFileName", value) + extension (name: WorkflowFileName) /** The name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala index f66e736..7360ef5 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. @@ -114,6 +115,16 @@ object MirrorName: def from(value: String): Either[ValidationError, MirrorName] = PathSegment.from("mirrorName", value) + /** Builds a mirror name from a string literal, checked while the code compiles. + * + * `MirrorName("...")` '''is''' the mirror name, with no `Either` to unwrap: a literal is either valid or it is not, + * and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): MirrorName = + SegmentLiteral.plain("mirrorName", value) + extension (name: MirrorName) /** The name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/RefName.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/RefName.scala index 28973d0..19fc488 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/RefName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/RefName.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.gitdata import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The name of a Git reference, whole or partial — `refs/heads/main`, `heads/main`, `tags/v1.2`, `main`. @@ -34,6 +35,16 @@ object RefName: def from(value: String): Either[ValidationError, RefName] = PathSegment.segmented("ref", value) + /** Builds a ref name from a string literal, checked while the code compiles. + * + * `RefName("...")` '''is''' the ref name, with no `Either` to unwrap: a literal is either valid or it is not, and an + * invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): RefName = + SegmentLiteral.segmented("ref", value) + extension (ref: RefName) /** The name as Git spells it, slashes included. Also what goes into a `ref` query parameter. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala index d3aebd4..032c3bd 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of one webhook — the `{id}` of `/repos/{owner}/{repo}/hooks/{id}`. @@ -62,6 +63,16 @@ object GitHookName: def from(value: String): Either[ValidationError, GitHookName] = PathSegment.from("gitHookName", value) + /** Builds a Git hook name from a string literal, checked while the code compiles. + * + * `GitHookName("...")` '''is''' the Git hook name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): GitHookName = + SegmentLiteral.plain("gitHookName", value) + extension (name: GitHookName) /** The name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlag.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlag.scala index a9abf38..c5f8dea 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlag.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlag.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** One administrative flag attached to a repository — the `{flag}` of `/repos/{owner}/{repo}/flags/{flag}`. @@ -37,6 +38,16 @@ object RepositoryFlag: def from(value: String): Either[ValidationError, RepositoryFlag] = PathSegment.from("repositoryFlag", value) + /** Builds a flag from a string literal, checked while the code compiles. + * + * `RepositoryFlag("...")` '''is''' the flag, with no `Either` to unwrap: a literal is either valid or it is not, and + * an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): RepositoryFlag = + SegmentLiteral.plain("repositoryFlag", value) + extension (flag: RepositoryFlag) /** The flag as a string, ready to be used as one path segment or rendered into a request body. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPageName.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPageName.scala index 2424c8f..14ac09c 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPageName.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPageName.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The name of a wiki page, as `GET /repos/{owner}/{repo}/wiki/page/{pageName}` spells it. @@ -37,6 +38,16 @@ object WikiPageName: def from(value: String): Either[ValidationError, WikiPageName] = PathSegment.segmented("pageName", value) + /** Builds a wiki page name from a string literal, checked while the code compiles. + * + * `WikiPageName("...")` '''is''' the wiki page name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): WikiPageName = + SegmentLiteral.segmented("pageName", value) + extension (name: WikiPageName) /** The name as Forgejo spells it, slashes and spaces included. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala index ebbf167..6e81cb2 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.publishing import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** One repository topic — `forge`, `forgejo`, `git`, `self-hosted` on `golden/repository/topics.json`. @@ -35,6 +36,16 @@ object Topic: def from(value: String): Either[ValidationError, Topic] = PathSegment.from("topic", value) + /** Builds a topic name from a string literal, checked while the code compiles. + * + * `Topic("...")` '''is''' the topic name, with no `Either` to unwrap: a literal is either valid or it is not, and an + * invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): Topic = + SegmentLiteral.plain("topic", value) + extension (topic: Topic) /** The name as Forgejo spells it, ready to be used as one path segment or as one element of a `topics` array. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala b/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala index 3db01bd..091c7f5 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/Username.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.users import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError /** The handle that names a person — the `{username}` of `/users/{username}`. @@ -42,6 +43,16 @@ object Username: def from(value: String): Either[ValidationError, Username] = PathSegment.from(Field, value) + /** Builds a username from a string literal, checked while the code compiles. + * + * `Username("...")` '''is''' the username, with no `Either` to unwrap: a literal is either valid or it is not, and + * an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the trim — + * surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and use + * [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): Username = + SegmentLiteral.plain("username", value) + extension (username: Username) /** The username as a string, ready to be used as one path segment. */ diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/social/AccessToken.scala b/modules/domain/src/com/worxbend/codeberg4s/users/social/AccessToken.scala index 2152c3d..71af73a 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/social/AccessToken.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/social/AccessToken.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.users.social import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.ApiToken import com.worxbend.codeberg4s.repositories.RepoSlug @@ -37,6 +38,16 @@ object AccessTokenName: def from(value: String): Either[ValidationError, AccessTokenName] = PathSegment.from(Field, value) + /** Builds a token name from a string literal, checked while the code compiles. + * + * `AccessTokenName("...")` '''is''' the token name, with no `Either` to unwrap: a literal is either valid or it is + * not, and an invalid one is a compile error pointing at the literal itself. The rules are [[from]]'s, minus the + * trim — surrounding whitespace is refused rather than removed. See [[com.worxbend.codeberg4s.SegmentLiteral]], and + * use [[from]] for a value known only at run time. + */ + inline def apply[V <: String & Singleton](inline value: V): AccessTokenName = + SegmentLiteral.plain("accessTokenName", value) + extension (name: AccessTokenName) /** The name as a string, ready to be used as one path segment. */ diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala new file mode 100644 index 0000000..6efabc2 --- /dev/null +++ b/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala @@ -0,0 +1,96 @@ +package com.worxbend.codeberg4s + +import munit.FunSuite + +import com.worxbend.codeberg4s.repositories.BranchName + +/** What the compile-time identifier constructors promise. + * + * Two of these tests are ordinary assertions about accepted literals. The rest are the interesting ones: they use + * munit's `compileErrors`, which compiles a snippet and hands back the compiler's message instead of failing the + * build, so a literal that '''must not''' compile can be asserted on like any other value. + * + * The last two are the drift guard [[SegmentLiteral]]'s Scaladoc promises. The rule is written twice — once as + * [[PathSegment]]'s `if`/`else` chain, once as a regular expression — and nothing in the compiler ties the two + * spellings together. These walk a corpus of awkward values through both and demand the same verdict, so changing one + * without the other fails here rather than leaving a value the run-time parser rejects and the compiler waves through. + */ +final class SegmentLiteralSuite extends FunSuite: + + test("a valid literal becomes the identifier with no Either to unwrap"): + assertEquals(Owner("forgejo").value, "forgejo") + assertEquals(RepoName("code.berg-4s_v1").value, "code.berg-4s_v1") + + test("a slashed literal is accepted where the identifier legitimately spans segments"): + assertEquals(BranchName("v16.0/forgejo").value, "v16.0/forgejo") + + test("a literal containing a slash does not compile where one segment is required"): + assert(compileErrors("""Owner("forgejo/forgejo")""").contains("not a valid owner")) + + test("a traversal literal does not compile"): + assert(compileErrors("""Owner("..")""").contains("not a valid owner")) + assert(compileErrors("""BranchName("release/../main")""").contains("not a valid branch")) + + test("an empty literal does not compile"): + assert(compileErrors("""RepoName("")""").contains("not a valid repoName")) + + test("a literal with surrounding whitespace does not compile, rather than being trimmed"): + assert(compileErrors("""Owner(" forgejo ")""").contains("not a valid owner")) + + test("a value known only at run time is sent to `from` instead"): + val message = compileErrors("""val raw: String = "forgejo"; Owner(raw)""") + assert(message.contains("string literal"), message) + + test("the compile-time rule for one segment agrees with PathSegment.from"): + val plain = scala.compiletime.constValue[SegmentLiteral.Plain] + Corpus.foreach: candidate => + assertEquals( + candidate.matches(plain), + PathSegment.from("field", candidate).isRight, + s"disagreement on ${escape(candidate)}", + ) + + test("the compile-time rule for several segments agrees with PathSegment.segmented"): + val segmented = scala.compiletime.constValue[SegmentLiteral.Segmented] + Corpus.foreach: candidate => + assertEquals( + candidate.matches(segmented), + PathSegment.segmented("field", candidate).isRight, + s"disagreement on ${escape(candidate)}", + ) + + /** Values chosen to sit on the edges of both spellings of the rule. + * + * Every entry is already equal to its own `trim`, because that is the one place the two rules are meant to + * disagree: `PathSegment` trims and the literal check refuses whitespace outright, so a padded value would report a + * difference that is intended rather than a drift. + */ + private val Corpus: List[String] = List( + "forgejo", + "a", + "code.berg-4s_v1", + "_CYBER_STONES_", + "-_-", + "...", + ".hidden", + "..hidden", + "", + ".", + "..", + "a/b", + "v16.0/forgejo", + "/main", + "main/", + "a//b", + "a/../b", + "a/./b", + "a/..", + "../b", + "forge\njo", + "forge\tjo", + "a b", + "ab", + ) + + private def escape(candidate: String): String = + candidate.flatMap(character => if character.isControl then f"\\u${character.toInt}%04x" else character.toString) From 194f70863003f83d8a9824c53b3682610f4484e6 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:02:12 +0300 Subject: [PATCH 10/31] refactor(core): add shared request-shape builders to CodebergRequest Every API companion in the client module privately defines the same handful of CodebergRequest constructor calls: a GET with a query, a mutation with a JSON body, a body-less mutation, a DELETE with and without a body, and an upload. Fourteen copies of `read`, nine of `write`, and the body-less mutation under three different names (`remove`, `bare`, `mutate`) depending on which package you are in. Duplication of a constructor is not only noise. Every copy repeats `headers = Nil`, and a copy that quietly forgot to would be indistinguishable from one that did not until a credential appeared in a log line. Writing the shapes once, in the companion of the type they build, is what turns the security contract on CodebergRequest into something checkable: no builder takes headers, so no call site can set one. This commit only adds the builders, marked private[codeberg4s] so they stay out of the public API. Following commits point the existing call sites at them and delete the per-package copies. --- .../codeberg4s/core/CodebergRequest.scala | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/modules/core/src/com/worxbend/codeberg4s/core/CodebergRequest.scala b/modules/core/src/com/worxbend/codeberg4s/core/CodebergRequest.scala index cc78389..5d81c4b 100644 --- a/modules/core/src/com/worxbend/codeberg4s/core/CodebergRequest.scala +++ b/modules/core/src/com/worxbend/codeberg4s/core/CodebergRequest.scala @@ -37,3 +37,98 @@ final case class CodebergRequest( headers: List[(String, String)], body: Option[RequestBody], ) + +/** The handful of request shapes every endpoint in the library is built from. + * + * Before these existed, each of the twenty-odd API companions carried its own private copy of the same six-line + * constructor call, under whichever name that file happened to pick — the body-less mutation alone was spelled + * `remove`, `bare` and `mutate` in three different packages. That is the duplication `docs/LEDGER.md` records as a + * review-blocking defect, and it costs more than tidiness: a copy that quietly forgot to leave `headers` empty would + * be indistinguishable from one that did not until a credential turned up in a log line. + * + * Building every request here, once, is what makes the security contract of [[CodebergRequest]] checkable rather than + * merely documented. None of these builders takes headers, so no call site anywhere in the library can set one, and + * therefore none can set an `Authorization` one; credentials are attached by the transport, from + * [[com.worxbend.codeberg4s.auth.Auth]], and nowhere else. + * + * Internal to the library: these are the vocabulary the endpoint modules share, not part of the public API. + */ +object CodebergRequest: + + /** A `GET`, with a query that may be empty. */ + private[codeberg4s] def read( + operation: String, + path: List[String], + query: List[(String, String)], + ): CodebergRequest = + CodebergRequest( + operation = operation, + method = HttpMethod.Get, + path = path, + query = query, + headers = Nil, + body = None, + ) + + /** A mutating call carrying a JSON body. */ + private[codeberg4s] def write( + operation: String, + method: HttpMethod, + path: List[String], + body: String, + ): CodebergRequest = + CodebergRequest( + operation = operation, + method = method, + path = path, + query = Nil, + headers = Nil, + body = Some(RequestBody.Json(body)), + ) + + /** A mutating call whose whole meaning is its method and its path. + * + * Forgejo's membership, subscription, block and team-assignment routes take no body at all: what is being said is + * said by the path. Sending `{}` on the chance the instance prefers it would be guesswork, and `docs/HAZARDS.md` §4 + * shows Forgejo answering `400` to bodies it did not expect. + */ + private[codeberg4s] def bodiless(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = + CodebergRequest( + operation = operation, + method = method, + path = path, + query = Nil, + headers = Nil, + body = None, + ) + + /** A `DELETE` with no body — the ordinary shape, where what to remove is named in the path. */ + private[codeberg4s] def remove(operation: String, path: List[String]): CodebergRequest = + bodiless(operation, HttpMethod.Delete, path) + + /** A `DELETE` that carries a JSON body, for the routes where what to remove is not in the URL. + * + * Reactions, blocks, dependencies, label removals and `DELETE /user/emails` all need this: the thing being removed + * is named in the payload and nowhere else, so there is no other way to issue the call. A body on a `DELETE` is + * unusual — RFC 9110 permits it and defines no semantics for it, and Forgejo defines its own — which is why this is + * a separate builder rather than an optional argument on [[remove]]: the oddity stays visible at the call sites that + * need it. + */ + private[codeberg4s] def removeWithBody(operation: String, path: List[String], body: String): CodebergRequest = + write(operation, HttpMethod.Delete, path, body) + + /** A `POST` carrying a non-JSON body and a query, which the attachment and asset uploads need. */ + private[codeberg4s] def upload( + operation: String, + path: List[String], + query: List[(String, String)], + body: RequestBody, + ): CodebergRequest = + CodebergRequest( + operation = operation, + method = HttpMethod.Post, + path = path, + query = query, + headers = Nil, + body = Some(body), + ) From 7ca436e272c88b29c569ef24c9987ce410f0964c Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:04:20 +0300 Subject: [PATCH 11/31] refactor(client): build issue requests from the shared shape builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue package had two homes for the same six constructor calls: IssueRequests, shared by the seven sub-APIs, and a private copy inside IssueApi, which predates it. Both are now gone in favour of the builders on the CodebergRequest companion, imported by name so the call sites still read `read(...)` and `write(...)`. IssueRequests keeps what is genuinely specific to this group: the path prefixes. IssueSubscriptionApi keeps its own builder too, renamed from `bodiless` to `emptyBody`, because it is not the same shape — it sends a zero-length body where the shared `bodiless` sends none at all, and two different requests sharing one name is how the wrong one gets picked. The upload request builders took a parameter named `upload`, which now shadows the shared builder of that name; it is `attachment` instead. --- .../worxbend/codeberg4s/issues/IssueApi.scala | 59 +++++------------ .../issues/IssueAttachmentApi.scala | 36 +++++----- .../codeberg4s/issues/IssueCommentApi.scala | 15 +++-- .../codeberg4s/issues/IssueLabelApi.scala | 20 +++--- .../codeberg4s/issues/IssueMilestoneApi.scala | 8 ++- .../codeberg4s/issues/IssueReactionApi.scala | 15 +++-- .../codeberg4s/issues/IssueRequests.scala | 66 ++----------------- .../issues/IssueSubscriptionApi.scala | 18 +++-- .../codeberg4s/issues/IssueTimeApi.scala | 11 ++-- 9 files changed, 95 insertions(+), 153 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala index 6c5c133..b05accd 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala @@ -9,9 +9,13 @@ import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.CommentDto import com.worxbend.codeberg4s.issues.wire.CreateIssueCommentOptionDto @@ -764,14 +768,14 @@ object IssueApi: exec.attempt(rail.timeline(owner, name, number, query, params)) private def searchRequest(query: IssueSearchQuery, params: PageParams): CodebergRequest = - IssueRequests.read( + read( SearchOperation, List("repos", "issues", "search"), IssueQueries.search(query) ++ IssueQueries.paging(params), ) private def deleteRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.remove(DeleteOperation, issuePath(owner, name, number)) + remove(DeleteOperation, issuePath(owner, name, number)) private def setDeadlineRequest( owner: Owner, @@ -779,7 +783,7 @@ object IssueApi: number: IssueNumber, dueDate: Instant, ): CodebergRequest = - IssueRequests.write( + write( SetDeadlineOperation, HttpMethod.Post, issuePath(owner, name, number) :+ "deadline", @@ -790,7 +794,7 @@ object IssueApi: bodiless(PinOperation, HttpMethod.Post, pinPath(owner, name, number)) private def unpinRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.remove(UnpinOperation, pinPath(owner, name, number)) + remove(UnpinOperation, pinPath(owner, name, number)) private def movePinRequest( owner: Owner, @@ -806,7 +810,7 @@ object IssueApi: number: IssueNumber, params: PageParams, ): CodebergRequest = - IssueRequests.read(ListBlocksOperation, blocksPath(owner, name, number), IssueQueries.paging(params)) + read(ListBlocksOperation, blocksPath(owner, name, number), IssueQueries.paging(params)) private def addBlockRequest( owner: Owner, @@ -814,7 +818,7 @@ object IssueApi: number: IssueNumber, blocked: IssueRef, ): CodebergRequest = - IssueRequests.write( + write( AddBlockOperation, HttpMethod.Post, blocksPath(owner, name, number), @@ -827,7 +831,7 @@ object IssueApi: number: IssueNumber, blocked: IssueRef, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( RemoveBlockOperation, blocksPath(owner, name, number), IssueMetaDto.render(blocked), @@ -839,7 +843,7 @@ object IssueApi: number: IssueNumber, params: PageParams, ): CodebergRequest = - IssueRequests.read(ListDependenciesOperation, dependenciesPath(owner, name, number), IssueQueries.paging(params)) + read(ListDependenciesOperation, dependenciesPath(owner, name, number), IssueQueries.paging(params)) private def addDependencyRequest( owner: Owner, @@ -847,7 +851,7 @@ object IssueApi: number: IssueNumber, blocker: IssueRef, ): CodebergRequest = - IssueRequests.write( + write( AddDependencyOperation, HttpMethod.Post, dependenciesPath(owner, name, number), @@ -860,7 +864,7 @@ object IssueApi: number: IssueNumber, blocker: IssueRef, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( RemoveDependencyOperation, dependenciesPath(owner, name, number), IssueMetaDto.render(blocker), @@ -873,23 +877,12 @@ object IssueApi: query: CommentQuery, params: PageParams, ): CodebergRequest = - IssueRequests.read( + read( TimelineOperation, issuePath(owner, name, number) :+ "timeline", IssueQueries.comments(query) ++ IssueQueries.paging(params), ) - /** A mutating call with no payload at all, which pinning and moving a pin both are. */ - private def bodiless(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - private def pinPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = issuePath(owner, name, number) :+ "pin" @@ -958,26 +951,6 @@ object IssueApi: private def getMilestoneRequest(owner: Owner, name: RepoName, id: MilestoneId): CodebergRequest = read(GetMilestoneOperation, milestonesPath(owner, name) :+ id.value.toString, Nil) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - private def issuesPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value, "issues") diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala index 05e984e..a0b8d55 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala @@ -6,6 +6,10 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.upload +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility @@ -387,19 +391,19 @@ object IssueAttachmentApi: exec.attempt(rail.deleteOnComment(owner, name, comment, id)) private def listForIssueRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.read(ListForIssueOperation, issueAssetsPath(owner, name, number), Nil) + read(ListForIssueOperation, issueAssetsPath(owner, name, number), Nil) private def uploadToIssueRequest( owner: Owner, name: RepoName, number: IssueNumber, - upload: UploadAttachment, + attachment: UploadAttachment, ): CodebergRequest = - IssueRequests.upload( + upload( UploadToIssueOperation, issueAssetsPath(owner, name, number), - IssueQueries.attachmentUpload(upload), - multipart(upload), + IssueQueries.attachmentUpload(attachment), + multipart(attachment), ) private def getOnIssueRequest( @@ -408,7 +412,7 @@ object IssueAttachmentApi: number: IssueNumber, id: AttachmentId, ): CodebergRequest = - IssueRequests.read(GetOnIssueOperation, issueAssetPath(owner, name, number, id), Nil) + read(GetOnIssueOperation, issueAssetPath(owner, name, number, id), Nil) private def editOnIssueRequest( owner: Owner, @@ -417,7 +421,7 @@ object IssueAttachmentApi: id: AttachmentId, command: EditAttachment, ): CodebergRequest = - IssueRequests.write( + write( EditOnIssueOperation, HttpMethod.Patch, issueAssetPath(owner, name, number, id), @@ -430,22 +434,22 @@ object IssueAttachmentApi: number: IssueNumber, id: AttachmentId, ): CodebergRequest = - IssueRequests.remove(DeleteOnIssueOperation, issueAssetPath(owner, name, number, id)) + remove(DeleteOnIssueOperation, issueAssetPath(owner, name, number, id)) private def listForCommentRequest(owner: Owner, name: RepoName, comment: CommentId): CodebergRequest = - IssueRequests.read(ListForCommentOperation, commentAssetsPath(owner, name, comment), Nil) + read(ListForCommentOperation, commentAssetsPath(owner, name, comment), Nil) private def uploadToCommentRequest( owner: Owner, name: RepoName, comment: CommentId, - upload: UploadAttachment, + attachment: UploadAttachment, ): CodebergRequest = - IssueRequests.upload( + upload( UploadToCommentOperation, commentAssetsPath(owner, name, comment), - IssueQueries.attachmentUpload(upload), - multipart(upload), + IssueQueries.attachmentUpload(attachment), + multipart(attachment), ) private def getOnCommentRequest( @@ -454,7 +458,7 @@ object IssueAttachmentApi: comment: CommentId, id: AttachmentId, ): CodebergRequest = - IssueRequests.read(GetOnCommentOperation, commentAssetPath(owner, name, comment, id), Nil) + read(GetOnCommentOperation, commentAssetPath(owner, name, comment, id), Nil) private def editOnCommentRequest( owner: Owner, @@ -463,7 +467,7 @@ object IssueAttachmentApi: id: AttachmentId, command: EditAttachment, ): CodebergRequest = - IssueRequests.write( + write( EditOnCommentOperation, HttpMethod.Patch, commentAssetPath(owner, name, comment, id), @@ -476,7 +480,7 @@ object IssueAttachmentApi: comment: CommentId, id: AttachmentId, ): CodebergRequest = - IssueRequests.remove(DeleteOnCommentOperation, commentAssetPath(owner, name, comment, id)) + remove(DeleteOnCommentOperation, commentAssetPath(owner, name, comment, id)) private def multipart(upload: UploadAttachment): RequestBody = RequestBody.Multipart(AttachmentFieldName, upload.fileName, upload.content, upload.mediaType) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala index 09bc47f..4c476d6 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueCommentApi.scala @@ -6,6 +6,9 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.EditIssueCommentOptionDto @@ -253,14 +256,14 @@ object IssueCommentApi: query: CommentQuery, params: PageParams, ): CodebergRequest = - IssueRequests.read( + read( ListForRepositoryOperation, IssueRequests.issuesPath(owner, name) :+ "comments", IssueQueries.comments(query) ++ IssueQueries.paging(params), ) private def getRequest(owner: Owner, name: RepoName, id: CommentId): CodebergRequest = - IssueRequests.read(GetOperation, IssueRequests.commentPath(owner, name, id), Nil) + read(GetOperation, IssueRequests.commentPath(owner, name, id), Nil) private def editRequest( owner: Owner, @@ -268,7 +271,7 @@ object IssueCommentApi: id: CommentId, command: EditComment, ): CodebergRequest = - IssueRequests.write( + write( EditOperation, HttpMethod.Patch, IssueRequests.commentPath(owner, name, id), @@ -276,7 +279,7 @@ object IssueCommentApi: ) private def deleteRequest(owner: Owner, name: RepoName, id: CommentId): CodebergRequest = - IssueRequests.remove(DeleteOperation, IssueRequests.commentPath(owner, name, id)) + remove(DeleteOperation, IssueRequests.commentPath(owner, name, id)) private def editDeprecatedRequest( owner: Owner, @@ -285,7 +288,7 @@ object IssueCommentApi: id: CommentId, command: EditComment, ): CodebergRequest = - IssueRequests.write( + write( EditDeprecatedOperation, HttpMethod.Patch, deprecatedPath(owner, name, number, id), @@ -298,7 +301,7 @@ object IssueCommentApi: number: IssueNumber, id: CommentId, ): CodebergRequest = - IssueRequests.remove(DeleteDeprecatedOperation, deprecatedPath(owner, name, number, id)) + remove(DeleteDeprecatedOperation, deprecatedPath(owner, name, number, id)) private def deprecatedPath( owner: Owner, diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala index fcdf8b2..3e65127 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueLabelApi.scala @@ -6,6 +6,10 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.EditLabelOptionDto @@ -319,10 +323,10 @@ object IssueLabelApi: exec.attempt(rail.clearOnIssue(owner, name, number, command)) private def getRequest(owner: Owner, name: RepoName, id: LabelId): CodebergRequest = - IssueRequests.read(GetOperation, IssueRequests.labelPath(owner, name, id), Nil) + read(GetOperation, IssueRequests.labelPath(owner, name, id), Nil) private def editRequest(owner: Owner, name: RepoName, id: LabelId, command: EditLabel): CodebergRequest = - IssueRequests.write( + write( EditOperation, HttpMethod.Patch, IssueRequests.labelPath(owner, name, id), @@ -330,10 +334,10 @@ object IssueLabelApi: ) private def deleteRequest(owner: Owner, name: RepoName, id: LabelId): CodebergRequest = - IssueRequests.remove(DeleteOperation, IssueRequests.labelPath(owner, name, id)) + remove(DeleteOperation, IssueRequests.labelPath(owner, name, id)) private def listOnIssueRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.read(ListOnIssueOperation, issueLabelsPath(owner, name, number), Nil) + read(ListOnIssueOperation, issueLabelsPath(owner, name, number), Nil) private def addToIssueRequest( owner: Owner, @@ -341,7 +345,7 @@ object IssueLabelApi: number: IssueNumber, command: LabelUpdate, ): CodebergRequest = - IssueRequests.write( + write( AddToIssueOperation, HttpMethod.Post, issueLabelsPath(owner, name, number), @@ -354,7 +358,7 @@ object IssueLabelApi: number: IssueNumber, command: LabelUpdate, ): CodebergRequest = - IssueRequests.write( + write( ReplaceOnIssueOperation, HttpMethod.Put, issueLabelsPath(owner, name, number), @@ -368,7 +372,7 @@ object IssueLabelApi: label: LabelRef, command: LabelRemoval, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( RemoveFromIssueOperation, issueLabelsPath(owner, name, number) :+ label.pathSegment, IssueLabelsOptionDto.renderRemoval(command), @@ -380,7 +384,7 @@ object IssueLabelApi: number: IssueNumber, command: LabelRemoval, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( ClearOnIssueOperation, issueLabelsPath(owner, name, number), IssueLabelsOptionDto.renderRemoval(command), diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala index 76ec6e6..ff4d003 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala @@ -6,6 +6,8 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.MilestoneOptionDto @@ -153,7 +155,7 @@ object IssueMilestoneApi: exec.attempt(rail.delete(owner, name, id)) private def createRequest(owner: Owner, name: RepoName, command: CreateMilestone): CodebergRequest = - IssueRequests.write( + write( CreateOperation, HttpMethod.Post, IssueRequests.repoPath(owner, name) :+ "milestones", @@ -166,7 +168,7 @@ object IssueMilestoneApi: id: MilestoneId, command: EditMilestone, ): CodebergRequest = - IssueRequests.write( + write( EditOperation, HttpMethod.Patch, IssueRequests.milestonePath(owner, name, id), @@ -174,4 +176,4 @@ object IssueMilestoneApi: ) private def deleteRequest(owner: Owner, name: RepoName, id: MilestoneId): CodebergRequest = - IssueRequests.remove(DeleteOperation, IssueRequests.milestonePath(owner, name, id)) + remove(DeleteOperation, IssueRequests.milestonePath(owner, name, id)) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala index a467a0b..6e1bee8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueReactionApi.scala @@ -6,6 +6,9 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.EditReactionOptionDto @@ -272,7 +275,7 @@ object IssueReactionApi: number: IssueNumber, params: PageParams, ): CodebergRequest = - IssueRequests.read(ListOnIssueOperation, issueReactionsPath(owner, name, number), IssueQueries.paging(params)) + read(ListOnIssueOperation, issueReactionsPath(owner, name, number), IssueQueries.paging(params)) private def addToIssueRequest( owner: Owner, @@ -280,7 +283,7 @@ object IssueReactionApi: number: IssueNumber, content: ReactionContent, ): CodebergRequest = - IssueRequests.write( + write( AddToIssueOperation, HttpMethod.Post, issueReactionsPath(owner, name, number), @@ -293,14 +296,14 @@ object IssueReactionApi: number: IssueNumber, content: ReactionContent, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( RemoveFromIssueOperation, issueReactionsPath(owner, name, number), EditReactionOptionDto.render(content), ) private def listOnCommentRequest(owner: Owner, name: RepoName, comment: CommentId): CodebergRequest = - IssueRequests.read(ListOnCommentOperation, commentReactionsPath(owner, name, comment), Nil) + read(ListOnCommentOperation, commentReactionsPath(owner, name, comment), Nil) private def addToCommentRequest( owner: Owner, @@ -308,7 +311,7 @@ object IssueReactionApi: comment: CommentId, content: ReactionContent, ): CodebergRequest = - IssueRequests.write( + write( AddToCommentOperation, HttpMethod.Post, commentReactionsPath(owner, name, comment), @@ -321,7 +324,7 @@ object IssueReactionApi: comment: CommentId, content: ReactionContent, ): CodebergRequest = - IssueRequests.removeWithBody( + removeWithBody( RemoveFromCommentOperation, commentReactionsPath(owner, name, comment), EditReactionOptionDto.render(content), diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala index 8c26833..0dc1f7f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala @@ -1,76 +1,18 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.core.CodebergRequest -import com.worxbend.codeberg4s.core.RequestBody -/** The request shapes and path prefixes every sub-API of the issue group builds on. +/** The path prefixes every sub-API of the issue group builds on, so that `/repos/{owner}/{repo}/issues` is spelled once + * rather than eight times. * - * [[IssueApi]] predates this object and keeps its own private copies of the four builders it was written with; nothing - * here changes what those do. The seven sub-APIs share these instead, so that `/repos/{owner}/{repo}/issues` is - * spelled once rather than eight times and a `DELETE` with a body cannot accidentally become one without. + * The request shapes these paths are handed to live in the companion of + * [[com.worxbend.codeberg4s.core.CodebergRequest]], shared with the whole library. * * Internal to this group. */ private[issues] object IssueRequests: - /** A `GET`, with a query that may be empty. */ - def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - /** A mutating call carrying a JSON body. */ - def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - /** A mutating call carrying a JSON body and a query, which only the attachment uploads need. */ - def upload( - operation: String, - path: List[String], - query: List[(String, String)], - body: RequestBody, - ): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Post, - path = path, - query = query, - headers = Nil, - body = Some(body), - ) - - /** A `DELETE` with no body — the ordinary shape. */ - def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - - /** A `DELETE` that carries a JSON body, which reactions, blocks, dependencies and label removals all need because - * what to remove is not in the URL. RFC 9110 permits this and defines no semantics for it; Forgejo defines its own. - */ - def removeWithBody(operation: String, path: List[String], body: String): CodebergRequest = - write(operation, HttpMethod.Delete, path, body) - /** `/repos/{owner}/{repo}`. */ def repoPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala index ad41af8..f8b260b 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueSubscriptionApi.scala @@ -6,6 +6,7 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility @@ -201,10 +202,10 @@ object IssueSubscriptionApi: number: IssueNumber, params: PageParams, ): CodebergRequest = - IssueRequests.read(ListOperation, subscriptionsPath(owner, name, number), IssueQueries.paging(params)) + read(ListOperation, subscriptionsPath(owner, name, number), IssueQueries.paging(params)) private def checkRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.read(CheckOperation, subscriptionsPath(owner, name, number) :+ "check", Nil) + read(CheckOperation, subscriptionsPath(owner, name, number) :+ "check", Nil) private def subscribeRequest( owner: Owner, @@ -212,7 +213,7 @@ object IssueSubscriptionApi: number: IssueNumber, login: Owner, ): CodebergRequest = - bodiless(SubscribeOperation, HttpMethod.Put, subscriptionsPath(owner, name, number) :+ login.value) + emptyBody(SubscribeOperation, HttpMethod.Put, subscriptionsPath(owner, name, number) :+ login.value) private def unsubscribeRequest( owner: Owner, @@ -220,9 +221,16 @@ object IssueSubscriptionApi: number: IssueNumber, login: Owner, ): CodebergRequest = - bodiless(UnsubscribeOperation, HttpMethod.Delete, subscriptionsPath(owner, name, number) :+ login.value) + emptyBody(UnsubscribeOperation, HttpMethod.Delete, subscriptionsPath(owner, name, number) :+ login.value) - private def bodiless(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = + /** A mutating call carrying a deliberately empty body. + * + * Deliberately not [[com.worxbend.codeberg4s.core.CodebergRequest.bodiless]], which sends no body at all: these two + * routes are among the Forgejo endpoints [[com.worxbend.codeberg4s.core.RequestBody.Empty]] exists for, and the + * difference — a zero-length body with a `Content-Length: 0` header, against no body and no header — is visible to + * the server. Keeping the two under different names keeps the choice from being made by accident. + */ + private def emptyBody(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = CodebergRequest( operation = operation, method = method, diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala index 4529a0a..71e06d7 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueTimeApi.scala @@ -6,6 +6,9 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.wire.AddTimeOptionDto @@ -312,7 +315,7 @@ object IssueTimeApi: query: TrackedTimeQuery, params: PageParams, ): CodebergRequest = - IssueRequests.read( + read( ListOperation, timesPath(owner, name, number), IssueQueries.trackedTimes(query) ++ IssueQueries.paging(params), @@ -324,7 +327,7 @@ object IssueTimeApi: number: IssueNumber, command: AddTrackedTime, ): CodebergRequest = - IssueRequests.write( + write( AddOperation, HttpMethod.Post, timesPath(owner, name, number), @@ -337,10 +340,10 @@ object IssueTimeApi: number: IssueNumber, id: TrackedTimeId, ): CodebergRequest = - IssueRequests.remove(DeleteOperation, timesPath(owner, name, number) :+ id.value.toString) + remove(DeleteOperation, timesPath(owner, name, number) :+ id.value.toString) private def resetRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - IssueRequests.remove(ResetOperation, timesPath(owner, name, number)) + remove(ResetOperation, timesPath(owner, name, number)) private def timesPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = IssueRequests.issuePath(owner, name, number) :+ "times" From 73edd116852de6ff3326a95b20fc843c250ff472 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:04:55 +0300 Subject: [PATCH 12/31] refactor(client): build organization requests from the shared builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrganizationRequests carried its own read, write and `bare` — the last being this package's name for the body-less mutation that the issue package called `remove` and the notification package `mutate`. All three were the same constructor call. The five API classes here now import the shared builders from the CodebergRequest companion, and `bare` becomes `bodiless`, the one name the library uses for that shape. The rationale `bare` documented — Forgejo's membership, block and team-assignment routes take no body at all, and answer 400 to bodies they did not expect — moved with it to the shared builder, so it is still stated where the decision is made. OrganizationRequests keeps the two path prefixes and the note on why a team is rooted at the instance rather than under its organisation. --- .../organizations/OrganizationApi.scala | 61 ++++++++++--------- .../organizations/OrganizationHookApi.scala | 13 ++-- .../organizations/OrganizationLabelApi.scala | 13 ++-- .../organizations/OrganizationQuotaApi.scala | 11 ++-- .../organizations/OrganizationRequests.scala | 49 +-------------- .../organizations/OrganizationTeamApi.scala | 25 ++++---- 6 files changed, 71 insertions(+), 101 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationApi.scala index 351a2e8..9bb3377 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.actions.OrganizationActionApi @@ -1006,13 +1009,13 @@ object OrganizationApi: case Left(error) => exec.raise(error) private def getRequest(org: OrgName): CodebergRequest = - OrganizationRequests.read(GetOperation, OrganizationRequests.organizationPath(org), Nil) + read(GetOperation, OrganizationRequests.organizationPath(org), Nil) private def listRequest(params: PageParams): CodebergRequest = - OrganizationRequests.read(ListOperation, List(OrganizationRequests.OrgsSegment), window(params)) + read(ListOperation, List(OrganizationRequests.OrgsSegment), window(params)) private def createRequest(command: CreateOrganization): CodebergRequest = - OrganizationRequests.write( + write( CreateOperation, HttpMethod.Post, List(OrganizationRequests.OrgsSegment), @@ -1020,7 +1023,7 @@ object OrganizationApi: ) private def editRequest(org: OrgName, command: EditOrganization): CodebergRequest = - OrganizationRequests.write( + write( EditOperation, HttpMethod.Patch, OrganizationRequests.organizationPath(org), @@ -1028,10 +1031,10 @@ object OrganizationApi: ) private def deleteRequest(org: OrgName): CodebergRequest = - OrganizationRequests.bare(DeleteOperation, HttpMethod.Delete, OrganizationRequests.organizationPath(org)) + bodiless(DeleteOperation, HttpMethod.Delete, OrganizationRequests.organizationPath(org)) private def renameRequest(org: OrgName, newName: OrgName): CodebergRequest = - OrganizationRequests.write( + write( RenameOperation, HttpMethod.Post, OrganizationRequests.organizationPath(org) :+ "rename", @@ -1039,7 +1042,7 @@ object OrganizationApi: ) private def updateAvatarRequest(org: OrgName, image: AvatarImage): CodebergRequest = - OrganizationRequests.write( + write( UpdateAvatarOperation, HttpMethod.Post, avatarPath(org), @@ -1047,13 +1050,13 @@ object OrganizationApi: ) private def deleteAvatarRequest(org: OrgName): CodebergRequest = - OrganizationRequests.bare(DeleteAvatarOperation, HttpMethod.Delete, avatarPath(org)) + bodiless(DeleteAvatarOperation, HttpMethod.Delete, avatarPath(org)) private def repositoriesRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(RepositoriesOperation, reposPath(org), window(params)) + read(RepositoriesOperation, reposPath(org), window(params)) private def createRepositoryRequest(org: OrgName, command: CreateRepository): CodebergRequest = - OrganizationRequests.write( + write( CreateRepositoryOperation, HttpMethod.Post, reposPath(org), @@ -1064,7 +1067,7 @@ object OrganizationApi: * [[OrganizationRequests.organizationPath]]. */ private def createRepositoryDeprecatedRequest(org: OrgName, command: CreateRepository): CodebergRequest = - OrganizationRequests.write( + write( CreateRepositoryDeprecatedOperation, HttpMethod.Post, List("org", org.value, "repos"), @@ -1072,86 +1075,86 @@ object OrganizationApi: ) private def membersRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(MembersOperation, membersPath(org), window(params)) + read(MembersOperation, membersPath(org), window(params)) private def isMemberRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.read(IsMemberOperation, memberPath(org, username), Nil) + read(IsMemberOperation, memberPath(org, username), Nil) private def removeMemberRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.bare(RemoveMemberOperation, HttpMethod.Delete, memberPath(org, username)) + bodiless(RemoveMemberOperation, HttpMethod.Delete, memberPath(org, username)) private def publicMembersRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(PublicMembersOperation, publicMembersPath(org), window(params)) + read(PublicMembersOperation, publicMembersPath(org), window(params)) private def isPublicMemberRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.read(IsPublicMemberOperation, publicMemberPath(org, username), Nil) + read(IsPublicMemberOperation, publicMemberPath(org, username), Nil) private def publicizeMemberRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.bare(PublicizeMemberOperation, HttpMethod.Put, publicMemberPath(org, username)) + bodiless(PublicizeMemberOperation, HttpMethod.Put, publicMemberPath(org, username)) private def concealMemberRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.bare(ConcealMemberOperation, HttpMethod.Delete, publicMemberPath(org, username)) + bodiless(ConcealMemberOperation, HttpMethod.Delete, publicMemberPath(org, username)) private def blockedUsersRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( BlockedUsersOperation, OrganizationRequests.organizationPath(org) :+ "list_blocked", window(params), ) private def blockUserRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.bare( + bodiless( BlockUserOperation, HttpMethod.Put, OrganizationRequests.organizationPath(org) ++ List("block", username.value), ) private def unblockUserRequest(org: OrgName, username: Username): CodebergRequest = - OrganizationRequests.bare( + bodiless( UnblockUserOperation, HttpMethod.Put, OrganizationRequests.organizationPath(org) ++ List("unblock", username.value), ) private def teamsRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( TeamsOperation, OrganizationRequests.organizationPath(org) :+ OrganizationRequests.TeamsSegment, window(params), ) private def getTeamRequest(id: TeamId): CodebergRequest = - OrganizationRequests.read(GetTeamOperation, OrganizationRequests.teamPath(id), Nil) + read(GetTeamOperation, OrganizationRequests.teamPath(id), Nil) private def teamMembersRequest(id: TeamId, params: PageParams): CodebergRequest = - OrganizationRequests.read(TeamMembersOperation, OrganizationRequests.teamPath(id) :+ "members", window(params)) + read(TeamMembersOperation, OrganizationRequests.teamPath(id) :+ "members", window(params)) private def teamRepositoriesRequest(id: TeamId, params: PageParams): CodebergRequest = - OrganizationRequests.read(TeamRepositoriesOperation, OrganizationRequests.teamPath(id) :+ "repos", window(params)) + read(TeamRepositoriesOperation, OrganizationRequests.teamPath(id) :+ "repos", window(params)) private def activitiesRequest(org: OrgName, date: Option[LocalDate], params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( ActivitiesOperation, OrganizationRequests.organizationPath(org) ++ List("activities", "feeds"), OrganizationQueries.activities(date, params), ) private def userOrganizationsRequest(username: Username, params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( UserOrganizationsOperation, OrganizationRequests.userPath(username) :+ OrganizationRequests.OrgsSegment, window(params), ) private def currentUserOrganizationsRequest(params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( CurrentUserOrganizationsOperation, List("user", OrganizationRequests.OrgsSegment), window(params), ) private def userPermissionsRequest(username: Username, org: OrgName): CodebergRequest = - OrganizationRequests.read( + read( UserPermissionsOperation, OrganizationRequests.userPath(username) ++ List(OrganizationRequests.OrgsSegment, org.value, "permissions"), Nil, diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationHookApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationHookApi.scala index d3cd055..c188572 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationHookApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationHookApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.wire.OrganizationQueries @@ -239,19 +242,19 @@ object OrganizationHookApi: exec.attempt(rail.delete(org, id)) private def listRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(ListOperation, hooksPath(org), OrganizationQueries.paging(params)) + read(ListOperation, hooksPath(org), OrganizationQueries.paging(params)) private def getRequest(org: OrgName, id: HookId): CodebergRequest = - OrganizationRequests.read(GetOperation, hookPath(org, id), Nil) + read(GetOperation, hookPath(org, id), Nil) private def createRequest(org: OrgName, command: CreateHook): CodebergRequest = - OrganizationRequests.write(CreateOperation, HttpMethod.Post, hooksPath(org), HookOptionDto.renderCreate(command)) + write(CreateOperation, HttpMethod.Post, hooksPath(org), HookOptionDto.renderCreate(command)) private def editRequest(org: OrgName, id: HookId, command: EditHook): CodebergRequest = - OrganizationRequests.write(EditOperation, HttpMethod.Patch, hookPath(org, id), HookOptionDto.renderEdit(command)) + write(EditOperation, HttpMethod.Patch, hookPath(org, id), HookOptionDto.renderEdit(command)) private def deleteRequest(org: OrgName, id: HookId): CodebergRequest = - OrganizationRequests.bare(DeleteOperation, HttpMethod.Delete, hookPath(org, id)) + bodiless(DeleteOperation, HttpMethod.Delete, hookPath(org, id)) private def hooksPath(org: OrgName): List[String] = OrganizationRequests.organizationPath(org) :+ HooksSegment diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApi.scala index f268c3a..a14d0fa 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.CreateLabel @@ -233,13 +236,13 @@ object OrganizationLabelApi: exec.attempt(rail.delete(org, id)) private def listRequest(org: OrgName, sort: Option[OrganizationLabelSort], params: PageParams): CodebergRequest = - OrganizationRequests.read(ListOperation, labelsPath(org), OrganizationQueries.labels(sort, params)) + read(ListOperation, labelsPath(org), OrganizationQueries.labels(sort, params)) private def getRequest(org: OrgName, id: LabelId): CodebergRequest = - OrganizationRequests.read(GetOperation, labelPath(org, id), Nil) + read(GetOperation, labelPath(org, id), Nil) private def createRequest(org: OrgName, command: CreateLabel): CodebergRequest = - OrganizationRequests.write( + write( CreateOperation, HttpMethod.Post, labelsPath(org), @@ -247,7 +250,7 @@ object OrganizationLabelApi: ) private def editRequest(org: OrgName, id: LabelId, command: EditLabel): CodebergRequest = - OrganizationRequests.write( + write( EditOperation, HttpMethod.Patch, labelPath(org, id), @@ -255,7 +258,7 @@ object OrganizationLabelApi: ) private def deleteRequest(org: OrgName, id: LabelId): CodebergRequest = - OrganizationRequests.bare(DeleteOperation, HttpMethod.Delete, labelPath(org, id)) + bodiless(DeleteOperation, HttpMethod.Delete, labelPath(org, id)) private def labelsPath(org: OrgName): List[String] = OrganizationRequests.organizationPath(org) :+ LabelsSegment diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationQuotaApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationQuotaApi.scala index 1b68760..0cb7beb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationQuotaApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationQuotaApi.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.wire.OrganizationQueries @@ -207,19 +208,19 @@ object OrganizationQuotaApi: exec.attempt(rail.packages(org, params)) private def getRequest(org: OrgName): CodebergRequest = - OrganizationRequests.read(GetOperation, quotaPath(org), Nil) + read(GetOperation, quotaPath(org), Nil) private def checkRequest(org: OrgName, subject: QuotaSubject): CodebergRequest = - OrganizationRequests.read(CheckOperation, quotaPath(org) :+ "check", OrganizationQueries.quotaCheck(subject)) + read(CheckOperation, quotaPath(org) :+ "check", OrganizationQueries.quotaCheck(subject)) private def artifactsRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(ArtifactsOperation, quotaPath(org) :+ "artifacts", OrganizationQueries.paging(params)) + read(ArtifactsOperation, quotaPath(org) :+ "artifacts", OrganizationQueries.paging(params)) private def attachmentsRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(AttachmentsOperation, quotaPath(org) :+ "attachments", OrganizationQueries.paging(params)) + read(AttachmentsOperation, quotaPath(org) :+ "attachments", OrganizationQueries.paging(params)) private def packagesRequest(org: OrgName, params: PageParams): CodebergRequest = - OrganizationRequests.read(PackagesOperation, quotaPath(org) :+ "packages", OrganizationQueries.paging(params)) + read(PackagesOperation, quotaPath(org) :+ "packages", OrganizationQueries.paging(params)) private def quotaPath(org: OrgName): List[String] = OrganizationRequests.organizationPath(org) :+ QuotaSegment diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationRequests.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationRequests.scala index 6716932..d7760fc 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationRequests.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationRequests.scala @@ -1,16 +1,11 @@ package com.worxbend.codeberg4s.organizations -import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.core.CodebergRequest -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.users.Username -/** The four request shapes the five API classes of this package build, and the two path prefixes they share. +/** The path prefixes the five API classes of this package share. * - * Shared rather than repeated once per class, for the reason - * [[com.worxbend.codeberg4s.repositories.hooks.HookRequests]] gives: five copies of the same six-line constructor call - * is exactly the duplication `docs/LEDGER.md` records as a review-blocking defect — and a copy that quietly forgot to - * leave `headers` empty would be indistinguishable from one that did not until a credential appeared in a log. + * The request shapes these paths are handed to live in the companion of + * [[com.worxbend.codeberg4s.core.CodebergRequest]], shared with the whole library. * * ==Two roots, and the difference is not cosmetic== * @@ -27,44 +22,6 @@ private[organizations] object OrganizationRequests: /** The instance-rooted collection segment `/teams`. */ val TeamsSegment: String = "teams" - /** A `GET` with no body. */ - def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - /** A mutating call carrying a JSON body. */ - def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - /** A mutating call whose whole meaning is its method and its path — every `PUT` in this group, and the deletes. - * - * Forgejo's membership, block and team-assignment routes take no body at all: what is being said is said by the - * path. Sending `{}` on the chance the instance prefers it would be guesswork, and `docs/HAZARDS.md` §4 shows - * Forgejo answering `400` to bodies it did not expect. - */ - def bare(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - /** The `/orgs/{org}` prefix. */ def organizationPath(org: OrgName): List[String] = List(OrgsSegment, org.value) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala index de2f569..413a6d6 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApi.scala @@ -5,6 +5,9 @@ import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.wire.OrganizationQueries @@ -450,7 +453,7 @@ object OrganizationTeamApi: exec.attempt(rail.removeRepository(id, org, name)) private def createRequest(org: OrgName, command: CreateTeam): CodebergRequest = - OrganizationRequests.write( + write( CreateOperation, HttpMethod.Post, OrganizationRequests.organizationPath(org) :+ OrganizationRequests.TeamsSegment, @@ -463,14 +466,14 @@ object OrganizationTeamApi: includeDescription: Option[Boolean], params: PageParams, ): CodebergRequest = - OrganizationRequests.read( + read( SearchOperation, OrganizationRequests.organizationPath(org) ++ List(OrganizationRequests.TeamsSegment, "search"), OrganizationQueries.teamSearch(text, includeDescription, params), ) private def editRequest(id: TeamId, command: EditTeam): CodebergRequest = - OrganizationRequests.write( + write( EditOperation, HttpMethod.Patch, OrganizationRequests.teamPath(id), @@ -478,32 +481,32 @@ object OrganizationTeamApi: ) private def deleteRequest(id: TeamId): CodebergRequest = - OrganizationRequests.bare(DeleteOperation, HttpMethod.Delete, OrganizationRequests.teamPath(id)) + bodiless(DeleteOperation, HttpMethod.Delete, OrganizationRequests.teamPath(id)) private def activitiesRequest(id: TeamId, date: Option[LocalDate], params: PageParams): CodebergRequest = - OrganizationRequests.read( + read( ActivitiesOperation, OrganizationRequests.teamPath(id) ++ List("activities", "feeds"), OrganizationQueries.activities(date, params), ) private def memberRequest(id: TeamId, username: Username): CodebergRequest = - OrganizationRequests.read(MemberOperation, memberPath(id, username), Nil) + read(MemberOperation, memberPath(id, username), Nil) private def addMemberRequest(id: TeamId, username: Username): CodebergRequest = - OrganizationRequests.bare(AddMemberOperation, HttpMethod.Put, memberPath(id, username)) + bodiless(AddMemberOperation, HttpMethod.Put, memberPath(id, username)) private def removeMemberRequest(id: TeamId, username: Username): CodebergRequest = - OrganizationRequests.bare(RemoveMemberOperation, HttpMethod.Delete, memberPath(id, username)) + bodiless(RemoveMemberOperation, HttpMethod.Delete, memberPath(id, username)) private def repositoryRequest(id: TeamId, org: OrgName, name: RepoName): CodebergRequest = - OrganizationRequests.read(RepositoryOperation, repositoryPath(id, org, name), Nil) + read(RepositoryOperation, repositoryPath(id, org, name), Nil) private def addRepositoryRequest(id: TeamId, org: OrgName, name: RepoName): CodebergRequest = - OrganizationRequests.bare(AddRepositoryOperation, HttpMethod.Put, repositoryPath(id, org, name)) + bodiless(AddRepositoryOperation, HttpMethod.Put, repositoryPath(id, org, name)) private def removeRepositoryRequest(id: TeamId, org: OrgName, name: RepoName): CodebergRequest = - OrganizationRequests.bare(RemoveRepositoryOperation, HttpMethod.Delete, repositoryPath(id, org, name)) + bodiless(RemoveRepositoryOperation, HttpMethod.Delete, repositoryPath(id, org, name)) private def memberPath(id: TeamId, username: Username): List[String] = OrganizationRequests.teamPath(id) ++ List(MembersSegment, username.value) From ce6431c0c53843bad8dedfc9e0ffd818fb039289 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:05:38 +0300 Subject: [PATCH 13/31] refactor(client): build account requests from the shared builders AccountRequests was the fourth home for the same four constructors. The five API classes of the account package now import read, write, remove and removeWithBody from the CodebergRequest companion instead. The object keeps the part that is specific to this package and is the reason it exists: the `user` path prefix, and the note that every endpoint here addresses whoever the configured credentials are, so none of them may stray into the `/users/{username}` family. Its security argument for building requests in one place now lives on the shared builders, which is where it applies to the whole library rather than to five classes. --- .../users/account/AccountRequests.scala | 64 ++----------------- .../users/account/UserAccountApi.scala | 26 ++++---- .../users/account/UserActionApi.scala | 29 +++++---- .../users/account/UserApplicationApi.scala | 13 ++-- .../users/account/UserHookApi.scala | 13 ++-- .../users/account/UserQuotaApi.scala | 11 ++-- 6 files changed, 57 insertions(+), 99 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/AccountRequests.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/AccountRequests.scala index b74d4ee..24836d8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/AccountRequests.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/AccountRequests.scala @@ -1,16 +1,10 @@ package com.worxbend.codeberg4s.users.account -import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.core.CodebergRequest -import com.worxbend.codeberg4s.core.RequestBody - -/** How the five API classes of this package build a [[com.worxbend.codeberg4s.core.CodebergRequest]]. +/** The path prefix the five API classes of this package share. * - * Five classes issuing thirty-eight requests would otherwise carry five copies of the same four constructors, and a - * copy that quietly forgot to leave `headers` empty would be indistinguishable from one that did not until a - * credential turned up in a log. Building them here, once, is also what makes the security contract of - * [[com.worxbend.codeberg4s.core.CodebergRequest]] checkable: nothing in this package ever sets a header, so nothing - * in it can set an `Authorization` one. + * The request shapes these paths are handed to live in the companion of + * [[com.worxbend.codeberg4s.core.CodebergRequest]], shared with the whole library, along with the security argument + * for building them in one place. * * '''Every path in this group starts with `user`''', because every one of these endpoints addresses whoever the * configured credentials are. [[AccountRequests.path]] is that prefix written down once, so no endpoint can @@ -25,53 +19,3 @@ private[account] object AccountRequests: /** The path `Root` followed by `rest`. */ def path(rest: String*): List[String] = Root :: rest.toList - - /** A `GET` with no body and no extra headers. */ - def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - /** A mutating request carrying a JSON body. */ - def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - /** A `DELETE` with no body. */ - def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - - /** A `DELETE` that carries a JSON body, which `DELETE /user/emails` is the library's only user of. - * - * A body on a `DELETE` is unusual and RFC 9110 gives it no defined semantics, but it is what `spec/swagger.v1.json` - * declares for `userDeleteEmail` — the addresses to remove are named in a `DeleteEmailOption` and nowhere else, so - * there is no other way to issue the call. It is a separate constructor rather than a flag on [[remove]] so that the - * oddity is visible at the one call site that needs it. - */ - def removeWithBody(operation: String, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala index 57ac87c..b9b5313 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountApi.scala @@ -4,6 +4,10 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.Team @@ -200,7 +204,7 @@ final class UserAccountApi private[codeberg4s] (pipeline: ApiPipeline[Future])(u * * '''This `DELETE` carries a body''', which is unusual and is the only one in the library: the addresses to remove * are named in a `DeleteEmailOption` and nowhere else, so there is no other way to issue the call. See - * [[AccountRequests.removeWithBody]]. + * [[removeWithBody]]. * * '''Retried''', because the request is idempotent by address: it names the exact values to remove, and after any * number of attempts those addresses are not on the account. Nothing is created, and — unlike a delete addressed by @@ -364,10 +368,10 @@ object UserAccountApi: exec.attempt(rail.teams(params)) private def settingsRequest: CodebergRequest = - AccountRequests.read(SettingsOperation, settingsPath, Nil) + read(SettingsOperation, settingsPath, Nil) private def updateSettingsRequest(command: UpdateUserSettings): CodebergRequest = - AccountRequests.write( + write( UpdateSettingsOperation, HttpMethod.Patch, settingsPath, @@ -375,7 +379,7 @@ object UserAccountApi: ) private def updateAvatarRequest(image: AvatarImage): CodebergRequest = - AccountRequests.write( + write( UpdateAvatarOperation, HttpMethod.Post, avatarPath, @@ -383,13 +387,13 @@ object UserAccountApi: ) private def deleteAvatarRequest: CodebergRequest = - AccountRequests.remove(DeleteAvatarOperation, avatarPath) + remove(DeleteAvatarOperation, avatarPath) private def emailsRequest: CodebergRequest = - AccountRequests.read(EmailsOperation, emailsPath, Nil) + read(EmailsOperation, emailsPath, Nil) private def addEmailsRequest(addresses: Vector[EmailAddress]): CodebergRequest = - AccountRequests.write( + write( AddEmailsOperation, HttpMethod.Post, emailsPath, @@ -397,17 +401,17 @@ object UserAccountApi: ) private def deleteEmailsRequest(addresses: Vector[EmailAddress]): CodebergRequest = - AccountRequests.removeWithBody(DeleteEmailsOperation, emailsPath, AccountOptionDto.renderEmails(addresses)) + removeWithBody(DeleteEmailsOperation, emailsPath, AccountOptionDto.renderEmails(addresses)) private def repositoriesRequest(order: RepositoryOrder, params: PageParams): CodebergRequest = - AccountRequests.read( + read( RepositoriesOperation, repositoriesPath, AccountQueries.paging(params) ++ AccountQueries.repositoryOrder(order), ) private def createRepositoryRequest(command: CreateRepository): CodebergRequest = - AccountRequests.write( + write( CreateRepositoryOperation, HttpMethod.Post, repositoriesPath, @@ -415,7 +419,7 @@ object UserAccountApi: ) private def teamsRequest(params: PageParams): CodebergRequest = - AccountRequests.read(TeamsOperation, AccountRequests.path("teams"), AccountQueries.paging(params)) + read(TeamsOperation, AccountRequests.path("teams"), AccountQueries.paging(params)) private def settingsPath: List[String] = AccountRequests.path("settings") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala index 079ee97..5ab7c0e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserActionApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page @@ -418,17 +421,17 @@ object UserActionApi: if command.renamedTo.isEmpty then RetryEligibility.AlwaysRetry else RetryEligibility.Never private def listRunnersRequest(visibility: RunnerVisibility, params: PageParams): CodebergRequest = - AccountRequests.read( + read( ListRunnersOperation, runnersPath, ActionQueries.runners(visibility) ++ ActionQueries.paging(params), ) private def runnerRequest(id: RunnerId): CodebergRequest = - AccountRequests.read(GetRunnerOperation, runnerPath(id), Nil) + read(GetRunnerOperation, runnerPath(id), Nil) private def registerRunnerRequest(command: RegisterRunner): CodebergRequest = - AccountRequests.write( + write( RegisterRunnerOperation, HttpMethod.Post, runnersPath, @@ -436,28 +439,28 @@ object UserActionApi: ) private def deleteRunnerRequest(id: RunnerId): CodebergRequest = - AccountRequests.remove(DeleteRunnerOperation, runnerPath(id)) + remove(DeleteRunnerOperation, runnerPath(id)) private def runnerRegistrationTokenRequest: CodebergRequest = - AccountRequests.read(RunnerRegistrationTokenOperation, runnersPath :+ "registration-token", Nil) + read(RunnerRegistrationTokenOperation, runnersPath :+ "registration-token", Nil) private def searchRunnerJobsRequest(labels: Vector[RunnerLabel]): CodebergRequest = - AccountRequests.read(SearchRunnerJobsOperation, runnersPath :+ "jobs", ActionQueries.runnerJobs(labels)) + read(SearchRunnerJobsOperation, runnersPath :+ "jobs", ActionQueries.runnerJobs(labels)) private def setSecretRequest(secret: SecretName, value: SecretValue): CodebergRequest = - AccountRequests.write(SetSecretOperation, HttpMethod.Put, secretPath(secret), SecretOptionDto.render(value)) + write(SetSecretOperation, HttpMethod.Put, secretPath(secret), SecretOptionDto.render(value)) private def deleteSecretRequest(secret: SecretName): CodebergRequest = - AccountRequests.remove(DeleteSecretOperation, secretPath(secret)) + remove(DeleteSecretOperation, secretPath(secret)) private def listVariablesRequest(params: PageParams): CodebergRequest = - AccountRequests.read(ListVariablesOperation, variablesPath, ActionQueries.paging(params)) + read(ListVariablesOperation, variablesPath, ActionQueries.paging(params)) private def variableRequest(name: VariableName): CodebergRequest = - AccountRequests.read(GetVariableOperation, variablePath(name), Nil) + read(GetVariableOperation, variablePath(name), Nil) private def createVariableRequest(name: VariableName, command: CreateVariable): CodebergRequest = - AccountRequests.write( + write( CreateVariableOperation, HttpMethod.Post, variablePath(name), @@ -465,7 +468,7 @@ object UserActionApi: ) private def updateVariableRequest(name: VariableName, command: UpdateVariable): CodebergRequest = - AccountRequests.write( + write( UpdateVariableOperation, HttpMethod.Put, variablePath(name), @@ -473,7 +476,7 @@ object UserActionApi: ) private def deleteVariableRequest(name: VariableName): CodebergRequest = - AccountRequests.remove(DeleteVariableOperation, variablePath(name)) + remove(DeleteVariableOperation, variablePath(name)) private def runnersPath: List[String] = AccountRequests.path("actions", "runners") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala index 9bb4dd9..48b9630 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page @@ -211,13 +214,13 @@ object UserApplicationApi: exec.attempt(rail.delete(id)) private def listRequest(params: PageParams): CodebergRequest = - AccountRequests.read(ListOperation, applicationsPath, AccountQueries.paging(params)) + read(ListOperation, applicationsPath, AccountQueries.paging(params)) private def getRequest(id: OAuth2ApplicationId): CodebergRequest = - AccountRequests.read(GetOperation, applicationPath(id), Nil) + read(GetOperation, applicationPath(id), Nil) private def createRequest(definition: OAuth2ApplicationDefinition): CodebergRequest = - AccountRequests.write( + write( CreateOperation, HttpMethod.Post, applicationsPath, @@ -225,7 +228,7 @@ object UserApplicationApi: ) private def updateRequest(id: OAuth2ApplicationId, definition: OAuth2ApplicationDefinition): CodebergRequest = - AccountRequests.write( + write( UpdateOperation, HttpMethod.Patch, applicationPath(id), @@ -233,7 +236,7 @@ object UserApplicationApi: ) private def deleteRequest(id: OAuth2ApplicationId): CodebergRequest = - AccountRequests.remove(DeleteOperation, applicationPath(id)) + remove(DeleteOperation, applicationPath(id)) private def applicationsPath: List[String] = AccountRequests.path("applications", "oauth2") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala index 5998fb2..3736347 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserHookApi.scala @@ -4,6 +4,9 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page @@ -197,19 +200,19 @@ object UserHookApi: exec.attempt(rail.delete(id)) private def listRequest(params: PageParams): CodebergRequest = - AccountRequests.read(ListOperation, hooksPath, AccountQueries.paging(params)) + read(ListOperation, hooksPath, AccountQueries.paging(params)) private def getRequest(id: HookId): CodebergRequest = - AccountRequests.read(GetOperation, hookPath(id), Nil) + read(GetOperation, hookPath(id), Nil) private def createRequest(command: CreateHook): CodebergRequest = - AccountRequests.write(CreateOperation, HttpMethod.Post, hooksPath, HookOptionDto.renderCreate(command)) + write(CreateOperation, HttpMethod.Post, hooksPath, HookOptionDto.renderCreate(command)) private def editRequest(id: HookId, command: EditHook): CodebergRequest = - AccountRequests.write(EditOperation, HttpMethod.Patch, hookPath(id), HookOptionDto.renderEdit(command)) + write(EditOperation, HttpMethod.Patch, hookPath(id), HookOptionDto.renderEdit(command)) private def deleteRequest(id: HookId): CodebergRequest = - AccountRequests.remove(DeleteOperation, hookPath(id)) + remove(DeleteOperation, hookPath(id)) private def hooksPath: List[String] = AccountRequests.path("hooks") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala index fe6905c..f9a4178 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserQuotaApi.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.users.account import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page @@ -181,19 +182,19 @@ object UserQuotaApi: exec.attempt(rail.packages(params)) private def infoRequest: CodebergRequest = - AccountRequests.read(InfoOperation, quotaPath, Nil) + read(InfoOperation, quotaPath, Nil) private def checkRequest(subject: QuotaSubject): CodebergRequest = - AccountRequests.read(CheckOperation, quotaPath :+ "check", AccountQueries.quotaCheck(subject)) + read(CheckOperation, quotaPath :+ "check", AccountQueries.quotaCheck(subject)) private def artifactsRequest(params: PageParams): CodebergRequest = - AccountRequests.read(ArtifactsOperation, quotaPath :+ "artifacts", AccountQueries.paging(params)) + read(ArtifactsOperation, quotaPath :+ "artifacts", AccountQueries.paging(params)) private def attachmentsRequest(params: PageParams): CodebergRequest = - AccountRequests.read(AttachmentsOperation, quotaPath :+ "attachments", AccountQueries.paging(params)) + read(AttachmentsOperation, quotaPath :+ "attachments", AccountQueries.paging(params)) private def packagesRequest(params: PageParams): CodebergRequest = - AccountRequests.read(PackagesOperation, quotaPath :+ "packages", AccountQueries.paging(params)) + read(PackagesOperation, quotaPath :+ "packages", AccountQueries.paging(params)) private def quotaPath: List[String] = AccountRequests.path("quota") From b517b30a1728e33fb13db6543b19ca9f07afed4d Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:08:56 +0300 Subject: [PATCH 14/31] refactor(client): delete the duplicate request builders that matched Eight API companions defined read, write or remove as byte-identical copies of the builders now on the CodebergRequest companion. They are deleted and the shared ones imported by name, so nothing at any call site changes shape. This is the bulk of the duplication and none of the judgement: every one of these copies was the same constructor call under the same name. The companions whose copies differed in signature are handled separately. --- .../actions/OrganizationActionApi.scala | 34 ++----------------- .../repositories/RepositoryApi.scala | 12 +------ .../access/RepositoryAccessApi.scala | 34 ++----------------- .../actions/RepositoryActionApi.scala | 34 ++----------------- .../publishing/RepositoryPublishingApi.scala | 33 ++---------------- .../worxbend/codeberg4s/users/UserApi.scala | 12 +------ .../codeberg4s/users/social/UserKeyApi.scala | 34 ++----------------- .../users/social/UserSocialApi.scala | 11 +----- 8 files changed, 18 insertions(+), 186 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala index cac2284..2472540 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApi.scala @@ -4,8 +4,10 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.OrgName import com.worxbend.codeberg4s.paging.Page @@ -537,36 +539,6 @@ object OrganizationActionApi: private def deleteVariableRequest(org: OrgName, name: VariableName): CodebergRequest = remove(DeleteVariableOperation, variablePath(org, name)) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - private def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - private def actionsPath(org: OrgName): List[String] = List("orgs", org.value, "actions") diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala index 4fe89bb..af9461c 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page @@ -415,16 +415,6 @@ object RepositoryApi: read(ListForksOperation, List("repos", owner.value, name.value, "forks"), window(params)) /** Every operation in this group is a `GET` that carries no body and adds no header of its own. */ - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - /** The `page` and `limit` parameters, in the order Forgejo's own `Link` header writes them. */ private def window(params: PageParams): List[(String, String)] = List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala index 2a726b4..d8d55eb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApi.scala @@ -6,8 +6,10 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.organizations.Team import com.worxbend.codeberg4s.paging.Page @@ -891,36 +893,6 @@ object RepositoryAccessApi: private def deleteTeamRequest(owner: Owner, name: RepoName, team: TeamName): CodebergRequest = remove(DeleteTeamOperation, teamPath(owner, name, team)) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - private def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - private def repoPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala index e27987b..6233311 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala @@ -6,8 +6,10 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams @@ -969,36 +971,6 @@ object RepositoryActionApi: DispatchWorkflowOptionDto.render(command), ) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - private def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - private def actionsPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value, "actions") diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala index 1be48ff..bcde01e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala @@ -6,6 +6,9 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility @@ -695,37 +698,7 @@ object RepositoryPublishingApi: GenerateRepoOptionDto.render(command), ) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - /** A `DELETE` with no body at all, which is what every deletion in this group is. */ - private def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - /** `page` and `limit`, always both — `limit` alone is silently ignored by some Forgejo endpoints. */ private def window(params: PageParams): List[(String, String)] = List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala index ed58654..2739386 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.users import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.core.Exec @@ -313,16 +313,6 @@ object UserApi: read(KeysOperation, List("users", username.value, "keys"), pageQuery(params)) /** A `GET` with no body and no extra headers, which is every operation in this group. */ - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - /** `page` and `limit`, always together. * * Sending `limit` alone is not a smaller version of this: the golden-fixture manifest records list endpoints that diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/UserKeyApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/UserKeyApi.scala index e6e6e95..f51da92 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/UserKeyApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/UserKeyApi.scala @@ -4,8 +4,10 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams @@ -346,33 +348,3 @@ object UserKeyApi: private def gpgKeyPath(id: GpgKeyId): List[String] = List("user", "gpg_keys", id.value.toString) - - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - - private def remove(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = None, - ) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala index 9033679..3b90e94 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/UserSocialApi.scala @@ -6,6 +6,7 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility @@ -653,16 +654,6 @@ object UserSocialApi: List("user", "starred", owner.value, name.value) /** A `GET` with no body and no extra headers. */ - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - /** A mutating request whose whole meaning is its method and path. * * Every `PUT` and `DELETE` in this group is one of these: Forgejo takes the subject from the path and declares no From d301c3bf02cc287e6b4320c612f9e591645ac425 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:09:07 +0300 Subject: [PATCH 15/31] refactor(client): reshape the last five private request builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These five companions had built the same requests as everyone else, but through signatures of their own, so deleting them meant adjusting call sites rather than only imports: - NotificationApi's `mutate` was `bodiless` under a third name; the calls now say `bodiless`. - MiscellaneousApi's `read` took no query at all. Its calls pass `Nil`, which is what the shared builder wants and what they meant. - RepositoryAdminApi's `remove` took an `Option[RequestBody]`, so every ordinary delete had to pass `None` and the one delete with a payload wrapped it by hand. Those are `remove` and `removeWithBody` now, and the odd one out is visible at its call site instead of behind an argument. - RepositoryGitApi's `write` took an `Option[String]` for the same reason. Its `DELETE` is a `remove`, and the two real writes pass the body directly. - PullRequestApi built everything through a general `send`. Its reads, writes and deletes use the shared builders; what remains is a local `post`, for the two calls no shared shape covers — the only mutation here with query parameters, and the only one wanting an explicitly empty body rather than none. --- .../miscellaneous/MiscellaneousApi.scala | 34 +++++-------- .../notifications/NotificationApi.scala | 31 ++---------- .../codeberg4s/pulls/PullRequestApi.scala | 42 ++++++---------- .../admin/RepositoryAdminApi.scala | 49 +++++-------------- .../gitdata/RepositoryGitApi.scala | 37 +++----------- 5 files changed, 49 insertions(+), 144 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala index eeef9f3..9353862 100644 --- a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala @@ -7,6 +7,7 @@ import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody @@ -545,25 +546,25 @@ object MiscellaneousApi: settingsRequest(UiSettingsOperation, "ui") private val SigningKeyRequest: CodebergRequest = - read(SigningKeyOperation, List("signing-key.gpg")) + read(SigningKeyOperation, List("signing-key.gpg"), Nil) private val SshSigningKeyRequest: CodebergRequest = - read(SshSigningKeyOperation, List("signing-key.ssh")) + read(SshSigningKeyOperation, List("signing-key.ssh"), Nil) private val GitignoreTemplatesRequest: CodebergRequest = - read(GitignoreTemplatesOperation, GitignoreTemplatesPath) + read(GitignoreTemplatesOperation, GitignoreTemplatesPath, Nil) private val LabelTemplatesRequest: CodebergRequest = - read(LabelTemplatesOperation, LabelTemplatesPath) + read(LabelTemplatesOperation, LabelTemplatesPath, Nil) private val LicenseTemplatesRequest: CodebergRequest = - read(LicenseTemplatesOperation, LicensesPath) + read(LicenseTemplatesOperation, LicensesPath, Nil) private val NodeInfoRequest: CodebergRequest = - read(NodeInfoOperation, List("nodeinfo")) + read(NodeInfoOperation, List("nodeinfo"), Nil) private val ActionsRunRequest: CodebergRequest = - read(ActionsRunOperation, List("actions", "run")) + read(ActionsRunOperation, List("actions", "run"), Nil) private val ApiSettingsDecoder: Decode[ServerApiSettings] = WireDecode.of(Json.decoder[ServerApiSettingsDto])(_.toDomain) @@ -615,16 +616,16 @@ object MiscellaneousApi: WireDecode.of(Json.decoder[ActionRunDto])(_.toDomain) private def settingsRequest(operation: String, area: String): CodebergRequest = - read(operation, List("settings", area)) + read(operation, List("settings", area), Nil) private def gitignoreTemplateRequest(name: TemplateName): CodebergRequest = - read(GitignoreTemplateOperation, GitignoreTemplatesPath :+ name.value) + read(GitignoreTemplateOperation, GitignoreTemplatesPath :+ name.value, Nil) private def labelTemplateRequest(name: TemplateName): CodebergRequest = - read(LabelTemplateOperation, LabelTemplatesPath :+ name.value) + read(LabelTemplateOperation, LabelTemplatesPath :+ name.value, Nil) private def licenseTemplateRequest(name: TemplateName): CodebergRequest = - read(LicenseTemplateOperation, LicensesPath :+ name.value) + read(LicenseTemplateOperation, LicensesPath :+ name.value, Nil) private def markupRequest(request: MarkupRenderRequest): CodebergRequest = CodebergRequest( @@ -636,17 +637,6 @@ object MiscellaneousApi: body = Some(RequestBody.Json(MarkupOptionDto.fromDomain(request).toJson)), ) - /** A `GET` with no query, no headers and no body — which is every read in this group. */ - private def read(operation: String, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = Nil, - headers = Nil, - body = None, - ) - private def markdownRequest(request: MarkdownRenderRequest): CodebergRequest = CodebergRequest( operation = RenderMarkdownOperation, diff --git a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala index 3d69fd6..fbe34b7 100644 --- a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala @@ -9,6 +9,8 @@ import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.bodiless +import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility @@ -281,7 +283,7 @@ object NotificationApi: ) private val markAllReadRequest: CodebergRequest = - mutate(MarkAllReadOperation, HttpMethod.Put, NotificationsPath) + bodiless(MarkAllReadOperation, HttpMethod.Put, NotificationsPath) private val unreadCountRequest: CodebergRequest = read(UnreadCountOperation, NotificationsPath :+ "new", Nil) @@ -290,7 +292,7 @@ object NotificationApi: read(GetThreadOperation, threadPath(id), Nil) private def markThreadReadRequest(id: NotificationThreadId): CodebergRequest = - mutate(MarkThreadReadOperation, HttpMethod.Patch, threadPath(id)) + bodiless(MarkThreadReadOperation, HttpMethod.Patch, threadPath(id)) private def listRepositoryRequest( owner: Owner, @@ -305,30 +307,7 @@ object NotificationApi: ) private def markRepositoryReadRequest(owner: Owner, name: RepoName): CodebergRequest = - mutate(MarkRepositoryReadOperation, HttpMethod.Put, repositoryNotificationsPath(owner, name)) - - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - /** A mark-read call: no query parameters and no body, which is what makes repeating it harmless. See the retry note - * on [[NotificationApi]]. - */ - private def mutate(operation: String, method: HttpMethod, path: List[String]): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = None, - ) + bodiless(MarkRepositoryReadOperation, HttpMethod.Put, repositoryNotificationsPath(owner, name)) private def threadPath(id: NotificationThreadId): List[String] = NotificationsPath ++ List("threads", id.value.toString) diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index 423ff04..89b24b2 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -9,6 +9,9 @@ import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RequestBody @@ -1201,7 +1204,7 @@ object PullRequestApi: read(MergeStatusOperation, pullPath(owner, name, number) :+ "merge", Nil) private def cancelMergeRequest(owner: Owner, name: RepoName, number: PullRequestNumber): CodebergRequest = - send(CancelScheduledMergeOperation, HttpMethod.Delete, pullPath(owner, name, number) :+ "merge", Nil, None) + remove(CancelScheduledMergeOperation, pullPath(owner, name, number) :+ "merge") private def updateBranchRequest( owner: Owner, @@ -1209,13 +1212,7 @@ object PullRequestApi: number: PullRequestNumber, style: UpdateStyle, ): CodebergRequest = - send( - UpdateBranchOperation, - HttpMethod.Post, - pullPath(owner, name, number) :+ "update", - PullRequestQueries.update(style), - None, - ) + post(UpdateBranchOperation, pullPath(owner, name, number) :+ "update", PullRequestQueries.update(style), None) private def requestReviewsRequest( owner: Owner, @@ -1284,7 +1281,7 @@ object PullRequestApi: number: PullRequestNumber, review: ReviewId, ): CodebergRequest = - send(DeleteReviewOperation, HttpMethod.Delete, reviewPath(owner, name, number, review), Nil, None) + remove(DeleteReviewOperation, reviewPath(owner, name, number, review)) private def dismissReviewRequest( owner: Owner, @@ -1312,9 +1309,8 @@ object PullRequestApi: number: PullRequestNumber, review: ReviewId, ): CodebergRequest = - send( + post( UndismissReviewOperation, - HttpMethod.Post, reviewPath(owner, name, number, review) :+ "undismissals", Nil, Some(RequestBody.Empty), @@ -1358,30 +1354,22 @@ object PullRequestApi: review: ReviewId, comment: ReviewCommentId, ): CodebergRequest = - send( - DeleteReviewCommentOperation, - HttpMethod.Delete, - reviewCommentPath(owner, name, number, review, comment), - Nil, - None, - ) - - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - send(operation, HttpMethod.Get, path, query, None) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - send(operation, method, path, Nil, Some(RequestBody.Json(body))) + remove(DeleteReviewCommentOperation, reviewCommentPath(owner, name, number, review, comment)) - private def send( + /** The two `POST`s in this group that carry no JSON body, which no shared builder covers. + * + * `update` is the only mutation here with query parameters, and `undismissals` is the only one that wants + * [[com.worxbend.codeberg4s.core.RequestBody.Empty]] rather than no body at all. + */ + private def post( operation: String, - method: HttpMethod, path: List[String], query: List[(String, String)], body: Option[RequestBody], ): CodebergRequest = CodebergRequest( operation = operation, - method = method, + method = HttpMethod.Post, path = path, query = query, headers = Nil, diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala index 2f872cb..ac174e9 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala @@ -6,8 +6,11 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.TrackedTime @@ -1173,7 +1176,7 @@ object RepositoryAdminApi: write(EditOperation, HttpMethod.Patch, repoPath(owner, name), RepositoryOptionDto.renderEdit(command)) private def deleteRequest(owner: Owner, name: RepoName): CodebergRequest = - remove(DeleteOperation, repoPath(owner, name), None) + remove(DeleteOperation, repoPath(owner, name)) private def migrateRequest(command: MigrateRepository): CodebergRequest = write(MigrateOperation, HttpMethod.Post, List("repos", "migrate"), MigrateRepoOptionsDto.render(command)) @@ -1208,7 +1211,7 @@ object RepositoryAdminApi: write(AddPushMirrorOperation, HttpMethod.Post, pushMirrorsPath(owner, name), PushMirrorOptionDto.render(command)) private def deletePushMirrorRequest(owner: Owner, name: RepoName, mirror: MirrorName): CodebergRequest = - remove(DeletePushMirrorOperation, pushMirrorsPath(owner, name) :+ mirror.value, None) + remove(DeletePushMirrorOperation, pushMirrorsPath(owner, name) :+ mirror.value) private def syncPushMirrorsRequest(owner: Owner, name: RepoName): CodebergRequest = post(SyncPushMirrorsOperation, repoPath(owner, name) :+ "push_mirrors-sync") @@ -1242,7 +1245,7 @@ object RepositoryAdminApi: ) private def unwatchRequest(owner: Owner, name: RepoName): CodebergRequest = - remove(UnwatchOperation, subscriptionPath(owner, name), None) + remove(UnwatchOperation, subscriptionPath(owner, name)) private def assigneesRequest(owner: Owner, name: RepoName): CodebergRequest = read(ListAssigneesOperation, repoPath(owner, name) :+ "assignees", Nil) @@ -1260,7 +1263,7 @@ object RepositoryAdminApi: write(CreateBranchOperation, HttpMethod.Post, branchesPath(owner, name), BranchOptionDto.renderCreate(command)) private def deleteBranchRequest(owner: Owner, name: RepoName, branch: BranchName): CodebergRequest = - remove(DeleteBranchOperation, branchesPath(owner, name) ++ branch.segments, None) + remove(DeleteBranchOperation, branchesPath(owner, name) ++ branch.segments) private def renameBranchRequest( owner: Owner, @@ -1311,10 +1314,10 @@ object RepositoryAdminApi: path: ContentPath, command: DeleteFile, ): CodebergRequest = - remove( + removeWithBody( DeleteFileOperation, contentsPath(owner, name) ++ path.segments, - Some(RequestBody.Json(FileOptionsDto.renderDelete(command))), + FileOptionsDto.renderDelete(command), ) private def changeFilesRequest(owner: Owner, name: RepoName, command: ChangeFiles): CodebergRequest = @@ -1324,7 +1327,7 @@ object RepositoryAdminApi: write(UpdateAvatarOperation, HttpMethod.Post, avatarPath(owner, name), AvatarOptionDto.render(image)) private def deleteAvatarRequest(owner: Owner, name: RepoName): CodebergRequest = - remove(DeleteAvatarOperation, avatarPath(owner, name), None) + remove(DeleteAvatarOperation, avatarPath(owner, name)) private def activityFeedRequest( owner: Owner, @@ -1372,26 +1375,6 @@ object RepositoryAdminApi: AdminQueries.topicSearch(keyword) ++ AdminQueries.paging(params), ) - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - private def write(operation: String, method: HttpMethod, path: List[String], body: String): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = Some(RequestBody.Json(body)), - ) - /** A `POST` that Forgejo declares no request model for — accept, reject, convert and the four sync calls. */ private def post(operation: String, path: List[String]): CodebergRequest = CodebergRequest( @@ -1403,16 +1386,6 @@ object RepositoryAdminApi: body = None, ) - private def remove(operation: String, path: List[String], body: Option[RequestBody]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Delete, - path = path, - query = Nil, - headers = Nil, - body = body, - ) - private def repoPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala index fb4699b..94bc7db 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala @@ -6,8 +6,10 @@ import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest +import com.worxbend.codeberg4s.core.CodebergRequest.read +import com.worxbend.codeberg4s.core.CodebergRequest.remove +import com.worxbend.codeberg4s.core.CodebergRequest.write import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.RequestBody import com.worxbend.codeberg4s.core.RetryEligibility import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams @@ -844,10 +846,10 @@ object RepositoryGitApi: read(GetNoteOperation, notePath(owner, name, sha), GitDataQueries.noteInclude(include)) private def setNoteRequest(owner: Owner, name: RepoName, sha: CommitSha, message: String): CodebergRequest = - write(SetNoteOperation, HttpMethod.Post, notePath(owner, name, sha), Some(NoteOptionsDto.render(message))) + write(SetNoteOperation, HttpMethod.Post, notePath(owner, name, sha), NoteOptionsDto.render(message)) private def removeNoteRequest(owner: Owner, name: RepoName, sha: CommitSha): CodebergRequest = - write(RemoveNoteOperation, HttpMethod.Delete, notePath(owner, name, sha), None) + remove(RemoveNoteOperation, notePath(owner, name, sha)) private def refsRequest(owner: Owner, name: RepoName): CodebergRequest = read(ListRefsOperation, gitPath(owner, name, "refs"), Nil) @@ -894,7 +896,7 @@ object RepositoryGitApi: ApplyDiffPatchOperation, HttpMethod.Post, repoPath(owner, name) :+ "diffpatch", - Some(DiffPatchOptionsDto.render(command)), + DiffPatchOptionsDto.render(command), ) private def editorConfigRequest( @@ -954,30 +956,3 @@ object RepositoryGitApi: private def notePath(owner: Owner, name: RepoName, sha: CommitSha): List[String] = gitPath(owner, name, "notes") :+ sha.value - - /** A `GET` carrying no body and adding no header of its own, which is every read in this group. */ - private def read(operation: String, path: List[String], query: List[(String, String)]): CodebergRequest = - CodebergRequest( - operation = operation, - method = HttpMethod.Get, - path = path, - query = query, - headers = Nil, - body = None, - ) - - /** A mutating call. `body` is absent for the `DELETE`, which sends none. */ - private def write( - operation: String, - method: HttpMethod, - path: List[String], - body: Option[String], - ): CodebergRequest = - CodebergRequest( - operation = operation, - method = method, - path = path, - query = Nil, - headers = Nil, - body = body.map(RequestBody.Json.apply), - ) From 4c87c5fed75e52e2a756a46e76e90a0bd692c135 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:12:36 +0300 Subject: [PATCH 16/31] refactor(client): read IssueApi decoders and paths from the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IssueApi was written before IssueDecoders and IssueRequests existed, so it carried its own private copies of eight response decoders and four path builders. Two decoders for the same JSON shape can drift apart without anything failing to compile, and the issue group had already begun to split: `search` decoded through IssueDecoders.issues while `list` decoded through a byte-identical private IssuesDecoder. IssueApi now takes every decoder from IssueDecoders and every path from IssueRequests, which is what the seven sub-APIs already did. Two things were added so that was possible: IssueDecoders.milestones, for the milestone listing, and IssueDecoders.presentComment, which is the existing comment decoder without the "an empty body means no comment" rule — posting a comment never answers 204, so a blank body there is a malformed response and should be reported as one. IssueRequests gains labelsPath and milestonesPath, and defines the single-object labelPath and milestonePath on top of them, so the collection segment is spelled once. IssueMilestoneApi's inline `repoPath(...) :+ "milestones"` now goes through milestonesPath too. No behaviour changes: every replacement decoder and path is the same value the removed private one produced. --- .../worxbend/codeberg4s/issues/IssueApi.scala | 108 ++++++------------ .../codeberg4s/issues/IssueDecoders.scala | 23 ++-- .../codeberg4s/issues/IssueMilestoneApi.scala | 2 +- .../codeberg4s/issues/IssueRequests.scala | 12 +- 4 files changed, 63 insertions(+), 82 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala index b05accd..89d258f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala @@ -2,11 +2,8 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.client.WireDecode -import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.CodebergRequest.bodiless @@ -14,20 +11,15 @@ import com.worxbend.codeberg4s.core.CodebergRequest.read import com.worxbend.codeberg4s.core.CodebergRequest.remove import com.worxbend.codeberg4s.core.CodebergRequest.removeWithBody import com.worxbend.codeberg4s.core.CodebergRequest.write -import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.Exec import com.worxbend.codeberg4s.core.RetryEligibility -import com.worxbend.codeberg4s.issues.wire.CommentDto import com.worxbend.codeberg4s.issues.wire.CreateIssueCommentOptionDto import com.worxbend.codeberg4s.issues.wire.CreateIssueOptionDto import com.worxbend.codeberg4s.issues.wire.CreateLabelOptionDto import com.worxbend.codeberg4s.issues.wire.EditDeadlineOptionDto import com.worxbend.codeberg4s.issues.wire.EditIssueOptionDto -import com.worxbend.codeberg4s.issues.wire.IssueDto import com.worxbend.codeberg4s.issues.wire.IssueMetaDto import com.worxbend.codeberg4s.issues.wire.IssueQueries -import com.worxbend.codeberg4s.issues.wire.LabelDto -import com.worxbend.codeberg4s.issues.wire.MilestoneDto import com.worxbend.codeberg4s.paging.Page import com.worxbend.codeberg4s.paging.PageParams @@ -126,7 +118,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * which window to fetch, and how large */ def list(owner: Owner, name: RepoName, query: IssueQuery, params: PageParams): Future[Page[Issue]] = - pipeline.callPage(IssueApi.listRequest(owner, name, query, params), params)(using IssueApi.IssuesDecoder) + pipeline.callPage(IssueApi.listRequest(owner, name, query, params), params)(using IssueDecoders.issues) /** Reads one issue — `GET /repos/{owner}/{repo}/issues/{index}`. * @@ -140,7 +132,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e def get(owner: Owner, name: RepoName, number: IssueNumber): Future[Issue] = val request = IssueApi.getRequest(owner, name, number) - pipeline.call(request, RetryEligibility.IdempotentOnly)(using IssueApi.IssueDecoder) + pipeline.call(request, RetryEligibility.IdempotentOnly)(using IssueDecoders.issue) /** Opens an issue — `POST /repos/{owner}/{repo}/issues`. * @@ -156,7 +148,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * what to create; built from [[CreateIssue.of]], which has already rejected a blank title */ def create(owner: Owner, name: RepoName, command: CreateIssue): Future[Issue] = - pipeline.call(IssueApi.createRequest(owner, name, command), RetryEligibility.Never)(using IssueApi.IssueDecoder) + pipeline.call(IssueApi.createRequest(owner, name, command), RetryEligibility.Never)(using IssueDecoders.issue) /** Edits an issue — `PATCH /repos/{owner}/{repo}/issues/{index}`. * @@ -172,8 +164,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * success as far as [[com.worxbend.codeberg4s.core.StatusMapping]] is concerned. */ def edit(owner: Owner, name: RepoName, number: IssueNumber, command: EditIssue): Future[Issue] = - pipeline.call(IssueApi.editRequest(owner, name, number, command), RetryEligibility.Never)(using - IssueApi.IssueDecoder) + pipeline.call(IssueApi.editRequest(owner, name, number, command), RetryEligibility.Never)(using IssueDecoders.issue) /** Lists an issue's comments — `GET /repos/{owner}/{repo}/issues/{index}/comments`. * @@ -193,7 +184,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * as [[com.worxbend.codeberg4s.CodebergError.Api]] like any other status. */ def listComments(owner: Owner, name: RepoName, number: IssueNumber, params: PageParams): Future[Page[Comment]] = - pipeline.callPage(IssueApi.listCommentsRequest(owner, name, number, params), params)(using IssueApi.CommentsDecoder) + pipeline.callPage(IssueApi.listCommentsRequest(owner, name, number, params), params)(using IssueDecoders.comments) /** Comments on an issue — `POST /repos/{owner}/{repo}/issues/{index}/comments`. * @@ -209,7 +200,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e command: CreateComment, ): Future[Comment] = pipeline.call(IssueApi.createCommentRequest(owner, name, number, command), RetryEligibility.Never)(using - IssueApi.CommentDecoder) + IssueDecoders.presentComment) /** Lists a repository's labels — `GET /repos/{owner}/{repo}/labels`. * @@ -224,7 +215,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * '''Failures.''' The group contract above. */ def listLabels(owner: Owner, name: RepoName, params: PageParams): Future[Page[Label]] = - pipeline.callPage(IssueApi.listLabelsRequest(owner, name, params), params)(using IssueApi.LabelsDecoder) + pipeline.callPage(IssueApi.listLabelsRequest(owner, name, params), params)(using IssueDecoders.labels) /** Creates a label on a repository — `POST /repos/{owner}/{repo}/labels`. * @@ -237,7 +228,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e def createLabel(owner: Owner, name: RepoName, command: CreateLabel): Future[Label] = val request = IssueApi.createLabelRequest(owner, name, command) - pipeline.call(request, RetryEligibility.Never)(using IssueApi.LabelDecoder) + pipeline.call(request, RetryEligibility.Never)(using IssueDecoders.label) /** Lists a repository's milestones — `GET /repos/{owner}/{repo}/milestones`. * @@ -251,8 +242,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * endpoint has no other filter worth naming and Forgejo's silent default of open-only surprises callers */ def listMilestones(owner: Owner, name: RepoName, state: StateFilter, params: PageParams): Future[Page[Milestone]] = - pipeline.callPage(IssueApi.listMilestonesRequest(owner, name, state, params), params)(using - IssueApi.MilestonesDecoder) + pipeline.callPage(IssueApi.listMilestonesRequest(owner, name, state, params), params)(using IssueDecoders.milestones) /** Reads one milestone — `GET /repos/{owner}/{repo}/milestones/{id}`. * @@ -261,7 +251,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e */ def getMilestone(owner: Owner, name: RepoName, id: MilestoneId): Future[Milestone] = pipeline.call(IssueApi.getMilestoneRequest(owner, name, id), RetryEligibility.IdempotentOnly)(using - IssueApi.MilestoneDecoder) + IssueDecoders.milestone) /** Searches issues across every repository the caller can see — `GET /repos/issues/search`. * @@ -775,7 +765,7 @@ object IssueApi: ) private def deleteRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - remove(DeleteOperation, issuePath(owner, name, number)) + remove(DeleteOperation, IssueRequests.issuePath(owner, name, number)) private def setDeadlineRequest( owner: Owner, @@ -786,7 +776,7 @@ object IssueApi: write( SetDeadlineOperation, HttpMethod.Post, - issuePath(owner, name, number) :+ "deadline", + IssueRequests.issuePath(owner, name, number) :+ "deadline", EditDeadlineOptionDto.render(dueDate), ) @@ -879,27 +869,27 @@ object IssueApi: ): CodebergRequest = read( TimelineOperation, - issuePath(owner, name, number) :+ "timeline", + IssueRequests.issuePath(owner, name, number) :+ "timeline", IssueQueries.comments(query) ++ IssueQueries.paging(params), ) private def pinPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = - issuePath(owner, name, number) :+ "pin" + IssueRequests.issuePath(owner, name, number) :+ "pin" private def blocksPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = - issuePath(owner, name, number) :+ "blocks" + IssueRequests.issuePath(owner, name, number) :+ "blocks" private def dependenciesPath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = - issuePath(owner, name, number) :+ "dependencies" + IssueRequests.issuePath(owner, name, number) :+ "dependencies" private def listRequest(owner: Owner, name: RepoName, query: IssueQuery, params: PageParams): CodebergRequest = - read(ListOperation, issuesPath(owner, name), IssueQueries.issues(query) ++ IssueQueries.paging(params)) + read(ListOperation, IssueRequests.issuesPath(owner, name), IssueQueries.issues(query) ++ IssueQueries.paging(params)) private def getRequest(owner: Owner, name: RepoName, number: IssueNumber): CodebergRequest = - read(GetOperation, issuePath(owner, name, number), Nil) + read(GetOperation, IssueRequests.issuePath(owner, name, number), Nil) private def createRequest(owner: Owner, name: RepoName, command: CreateIssue): CodebergRequest = - write(CreateOperation, HttpMethod.Post, issuesPath(owner, name), CreateIssueOptionDto.render(command)) + write(CreateOperation, HttpMethod.Post, IssueRequests.issuesPath(owner, name), CreateIssueOptionDto.render(command)) private def editRequest( owner: Owner, @@ -907,7 +897,12 @@ object IssueApi: number: IssueNumber, command: EditIssue, ): CodebergRequest = - write(EditOperation, HttpMethod.Patch, issuePath(owner, name, number), EditIssueOptionDto.render(command)) + write( + EditOperation, + HttpMethod.Patch, + IssueRequests.issuePath(owner, name, number), + EditIssueOptionDto.render(command), + ) private def listCommentsRequest( owner: Owner, @@ -915,7 +910,7 @@ object IssueApi: number: IssueNumber, params: PageParams, ): CodebergRequest = - read(ListCommentsOperation, issuePath(owner, name, number) :+ "comments", IssueQueries.paging(params)) + read(ListCommentsOperation, IssueRequests.issuePath(owner, name, number) :+ "comments", IssueQueries.paging(params)) private def createCommentRequest( owner: Owner, @@ -926,15 +921,20 @@ object IssueApi: write( CreateCommentOperation, HttpMethod.Post, - issuePath(owner, name, number) :+ "comments", + IssueRequests.issuePath(owner, name, number) :+ "comments", CreateIssueCommentOptionDto.render(command), ) private def listLabelsRequest(owner: Owner, name: RepoName, params: PageParams): CodebergRequest = - read(ListLabelsOperation, labelsPath(owner, name), IssueQueries.paging(params)) + read(ListLabelsOperation, IssueRequests.labelsPath(owner, name), IssueQueries.paging(params)) private def createLabelRequest(owner: Owner, name: RepoName, command: CreateLabel): CodebergRequest = - write(CreateLabelOperation, HttpMethod.Post, labelsPath(owner, name), CreateLabelOptionDto.render(command)) + write( + CreateLabelOperation, + HttpMethod.Post, + IssueRequests.labelsPath(owner, name), + CreateLabelOptionDto.render(command), + ) private def listMilestonesRequest( owner: Owner, @@ -944,45 +944,9 @@ object IssueApi: ): CodebergRequest = read( ListMilestonesOperation, - milestonesPath(owner, name), + IssueRequests.milestonesPath(owner, name), IssueQueries.milestones(state) ++ IssueQueries.paging(params), ) private def getMilestoneRequest(owner: Owner, name: RepoName, id: MilestoneId): CodebergRequest = - read(GetMilestoneOperation, milestonesPath(owner, name) :+ id.value.toString, Nil) - - private def issuesPath(owner: Owner, name: RepoName): List[String] = - List("repos", owner.value, name.value, "issues") - - private def issuePath(owner: Owner, name: RepoName, number: IssueNumber): List[String] = - issuesPath(owner, name) :+ number.value.toString - - private def labelsPath(owner: Owner, name: RepoName): List[String] = - List("repos", owner.value, name.value, "labels") - - private def milestonesPath(owner: Owner, name: RepoName): List[String] = - List("repos", owner.value, name.value, "milestones") - - private val IssueDecoder: Decode[Issue] = - WireDecode.of(Json.decoder[IssueDto])(_.toDomain) - - private val IssuesDecoder: Decode[Vector[Issue]] = - WireDecode.of(Json.decoder[Vector[IssueDto]])(dtos => IssueDto.toDomainAll(JsonPath.Root, dtos)) - - private val CommentDecoder: Decode[Comment] = - WireDecode.of(Json.decoder[CommentDto])(_.toDomain) - - private val CommentsDecoder: Decode[Vector[Comment]] = - WireDecode.of(Json.decoder[Vector[CommentDto]])(dtos => CommentDto.toDomainAll(JsonPath.Root, dtos)) - - private val LabelDecoder: Decode[Label] = - WireDecode.of(Json.decoder[LabelDto])(_.toDomain) - - private val LabelsDecoder: Decode[Vector[Label]] = - WireDecode.of(Json.decoder[Vector[LabelDto]])(dtos => LabelDto.toDomainAll(JsonPath.Root, dtos)) - - private val MilestoneDecoder: Decode[Milestone] = - WireDecode.of(Json.decoder[MilestoneDto])(_.toDomain) - - private val MilestonesDecoder: Decode[Vector[Milestone]] = - WireDecode.of(Json.decoder[Vector[MilestoneDto]])(dtos => MilestoneDto.toDomainAll(JsonPath.Root, dtos)) + read(GetMilestoneOperation, IssueRequests.milestonesPath(owner, name) :+ id.value.toString, Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala index 18a5282..c0fdcb9 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala @@ -22,9 +22,8 @@ import com.worxbend.codeberg4s.users.wire.UserDto /** Every response shape the issue group's sub-APIs can receive, decoded once and shared. * * The instances are stateless and immutable, so they are built as `val`s rather than per call, exactly as - * [[com.worxbend.codeberg4s.repositories.actions.RepositoryActionDecoders]] does. [[IssueApi]] itself predates this - * object and keeps its own private copies of the four decoders it was written with; nothing here changes what those - * do. + * [[com.worxbend.codeberg4s.repositories.actions.RepositoryActionDecoders]] does. [[IssueApi]] and every sub-API read + * their decoders from here, so a change to how one shape is decoded lands everywhere at once. * * ==Every listing in this group is a bare array== * @@ -43,7 +42,15 @@ private[issues] object IssueDecoders: val issues: Decode[Vector[Issue]] = WireDecode.of(Json.decoder[Vector[IssueDto]])(dtos => IssueDto.toDomainAll(JsonPath.Root, dtos)) - /** One comment object. + /** One comment object on an endpoint that always sends a body — posting a comment, where `201` is the only success. + * + * The difference from [[comment]] is only what an empty body means: nothing on those endpoints declares `204`, so a + * blank body is a malformed response and is reported as one rather than being read as "no comment". + */ + val presentComment: Decode[Comment] = + WireDecode.of(Json.decoder[CommentDto])(_.toDomain) + + /** One comment object, on the endpoints where an empty body is also a success. * * '''An empty body is a success here, not a decoding failure.''' Both the single-comment read and the comment edit * declare `204` alongside `200` in `spec/swagger.v1.json`, and Forgejo answers `204` when the row behind the id is @@ -54,9 +61,7 @@ private[issues] object IssueDecoders: * fails. See [[com.worxbend.codeberg4s.repositories.actions.RepositoryActionDecoders]] for the same shape. */ val comment: Decode[Option[Comment]] = - val present = WireDecode.of(Json.decoder[CommentDto])(_.toDomain) - - (body: ResponseBody) => if body.isBlank then Right(None) else present(body).map(Some.apply) + (body: ResponseBody) => if body.isBlank then Right(None) else presentComment(body).map(Some.apply) /** A bare array of comment objects, as the repository-wide comment listing returns it. */ val comments: Decode[Vector[Comment]] = @@ -74,6 +79,10 @@ private[issues] object IssueDecoders: val milestone: Decode[Milestone] = WireDecode.of(Json.decoder[MilestoneDto])(_.toDomain) + /** A bare array of milestone objects, as the repository's milestone listing returns it. */ + val milestones: Decode[Vector[Milestone]] = + WireDecode.of(Json.decoder[Vector[MilestoneDto]])(dtos => MilestoneDto.toDomainAll(JsonPath.Root, dtos)) + /** One attachment object. */ val attachment: Decode[IssueAttachment] = WireDecode.of(Json.decoder[AttachmentDto])(_.toDomain) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala index ff4d003..21f2a12 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueMilestoneApi.scala @@ -158,7 +158,7 @@ object IssueMilestoneApi: write( CreateOperation, HttpMethod.Post, - IssueRequests.repoPath(owner, name) :+ "milestones", + IssueRequests.milestonesPath(owner, name), MilestoneOptionDto.renderCreate(command), ) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala index 0dc1f7f..fe80867 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueRequests.scala @@ -29,10 +29,18 @@ private[issues] object IssueRequests: def commentPath(owner: Owner, name: RepoName, id: CommentId): List[String] = issuesPath(owner, name) ++ List("comments", id.value.toString) + /** `/repos/{owner}/{repo}/labels` — the labels a repository offers, not the ones on any one issue. */ + def labelsPath(owner: Owner, name: RepoName): List[String] = + repoPath(owner, name) :+ "labels" + /** `/repos/{owner}/{repo}/labels/{id}`. */ def labelPath(owner: Owner, name: RepoName, id: LabelId): List[String] = - repoPath(owner, name) ++ List("labels", id.value.toString) + labelsPath(owner, name) :+ id.value.toString + + /** `/repos/{owner}/{repo}/milestones`. */ + def milestonesPath(owner: Owner, name: RepoName): List[String] = + repoPath(owner, name) :+ "milestones" /** `/repos/{owner}/{repo}/milestones/{id}`. */ def milestonePath(owner: Owner, name: RepoName, id: MilestoneId): List[String] = - repoPath(owner, name) ++ List("milestones", id.value.toString) + milestonesPath(owner, name) :+ id.value.toString From 8925d7ab77a01e984234982f5786e5a3a4496b31 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:16:49 +0300 Subject: [PATCH 17/31] refactor(codec): render every paging window from one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-element `page`/`limit` list was written out in sixteen separate renderers — eleven `*Queries` objects and three private helpers inside client API classes — with two more one-off spellings for the routes that take only `page` or that call the size `per_page`. Each copy was a chance for one endpoint group to drift, and a drifted copy fails silently: Forgejo answers 200 with the wrong number of items rather than an error. In particular a `limit` sent without a `page` is ignored by some endpoints, which returns the entire collection — the unbounded fetch this library exists to prevent. `PagingQuery` now owns all three spellings, so each wire name is written exactly once as rule 4 of `WireConventions` requires. The per-group `paging` methods stay, because they are the names the API classes call and the place each group's own remarks belong; their bodies delegate, and the prose that was repeated eleven times is now a pointer to the one copy that carries the measurement behind it. No request changes shape: every existing suite asserting on a rendered query string passes unchanged. --- .../repositories/RepositoryApi.scala | 5 +- .../publishing/RepositoryPublishingApi.scala | 5 +- .../worxbend/codeberg4s/users/UserApi.scala | 10 ++-- .../codeberg4s/codec/PagingQuery.scala | 54 +++++++++++++++++++ .../codeberg4s/issues/wire/IssueQueries.scala | 11 ++-- .../wire/NotificationQueries.scala | 11 ++-- .../wire/OrganizationQueries.scala | 9 ++-- .../pulls/wire/PullRequestQueries.scala | 11 ++-- .../access/wire/AccessQueries.scala | 11 ++-- .../actions/wire/ActionQueries.scala | 11 ++-- .../admin/wire/AdminQueries.scala | 11 ++-- .../gitdata/wire/GitDataQueries.scala | 10 ++-- .../repositories/hooks/wire/HookQueries.scala | 22 ++++---- .../users/account/wire/AccountQueries.scala | 11 ++-- .../users/social/wire/SocialQueries.scala | 11 ++-- 15 files changed, 133 insertions(+), 70 deletions(-) create mode 100644 modules/codec/src/com/worxbend/codeberg4s/codec/PagingQuery.scala diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala index af9461c..69b025d 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryApi.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.CodebergRequest.read @@ -415,6 +416,6 @@ object RepositoryApi: read(ListForksOperation, List("repos", owner.value, name.value, "forks"), window(params)) /** Every operation in this group is a `GET` that carries no body and adds no header of its own. */ - /** The `page` and `limit` parameters, in the order Forgejo's own `Link` header writes them. */ + /** The `page` and `limit` window, rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]]. */ private def window(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala index bcde01e..c07d521 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApi.scala @@ -4,6 +4,7 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.CodebergRequest.read @@ -699,9 +700,9 @@ object RepositoryPublishingApi: ) /** A `DELETE` with no body at all, which is what every deletion in this group is. */ - /** `page` and `limit`, always both — `limit` alone is silently ignored by some Forgejo endpoints. */ + /** The `page` and `limit` window, rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]]. */ private def window(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) private def releasesPath(owner: Owner, name: RepoName): List[String] = List("repos", owner.value, name.value, "releases") diff --git a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala index 2739386..c5a2557 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala @@ -4,6 +4,7 @@ import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest import com.worxbend.codeberg4s.core.CodebergRequest.read @@ -313,14 +314,11 @@ object UserApi: read(KeysOperation, List("users", username.value, "keys"), pageQuery(params)) /** A `GET` with no body and no extra headers, which is every operation in this group. */ - /** `page` and `limit`, always together. - * - * Sending `limit` alone is not a smaller version of this: the golden-fixture manifest records list endpoints that - * ignore a lone `limit` and return the entire collection — 862 forks, 5233 stargazers — which is the unbounded fetch - * this library exists to prevent. + /** The `page` and `limit` window every listing here sends, rendered by + * [[com.worxbend.codeberg4s.codec.PagingQuery.window]]. */ private def pageQuery(params: PageParams): List[(String, String)] = - List(("page", params.page.value.toString), ("limit", params.size.value.toString)) + PagingQuery.window(params) private val UserDecoder: Decode[User] = WireDecode.of(Json.decoder[UserDto])(_.toDomain) diff --git a/modules/codec/src/com/worxbend/codeberg4s/codec/PagingQuery.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/PagingQuery.scala new file mode 100644 index 0000000..1d51783 --- /dev/null +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/PagingQuery.scala @@ -0,0 +1,54 @@ +package com.worxbend.codeberg4s.codec + +import com.worxbend.codeberg4s.paging.PageParams + +/** How a [[com.worxbend.codeberg4s.paging.PageParams]] is written into a query string. + * + * A paging window has three spellings across the Forgejo API and no more, so all three live here and each wire name — + * `page`, `limit`, `per_page` — is written exactly once in the library, as rule 4 of [[WireConventions]] requires. The + * per-group `*Queries` objects keep their own `paging` method as the name their API class calls, but the pair of + * strings itself comes from here. + * + * '''Why one renderer rather than one per endpoint group.''' Before this object the same two-element list appeared in + * sixteen places. Each copy was a chance for one group to drift — to send a `limit` without a `page`, or to keep the + * usual spelling on the one route that does not accept it — and a drifted copy fails silently: Forgejo answers `200` + * with the wrong number of items rather than an error. The three functions below are therefore the whole vocabulary, + * and a route that needs a fourth spelling is a route that needs a reviewed addition here. + * + * @see + * [[com.worxbend.codeberg4s.paging.PageParams]] for the validated window this renders + */ +private[codeberg4s] object PagingQuery: + + /** The `page` and `limit` parameters, as almost every Forgejo listing spells them. + * + * '''Both, always.''' `golden/MANIFEST.md` records that `limit` alone is silently ignored on some Forgejo endpoints + * — `?limit=2` against `/forks` returned all 862 forks, and adding `page=1` made the limit take effect — so sending + * a limit without a page is how a client accidentally pulls an unbounded collection. That measurement is the reason + * this function takes a whole [[com.worxbend.codeberg4s.paging.PageParams]] and emits both halves, instead of + * offering a caller the chance to send one of them. + * + * The order is `page` then `limit`, which is the order Forgejo's own `Link` header writes them in. Forgejo does not + * care, but a fixed order makes a recorded request comparable between runs. + */ + def window(params: PageParams): List[(String, String)] = + List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + + /** The `page` parameter on its own, for a route that declares no size parameter at all. + * + * Only `repoGetWikiPageRevisions` is in this shape. Sending it a `limit` on the chance that Forgejo reads it would + * be exactly the guesswork `docs/HAZARDS.md` warns against, so the instance chooses the page size and the caller's + * [[com.worxbend.codeberg4s.paging.PageParams.size]] does not reach the wire; where the collection ends is still + * decided by the `Link` header, as it is everywhere else. + */ + def pageOnly(params: PageParams): List[(String, String)] = + List("page" -> params.page.value.toString) + + /** The `page` and `per_page` parameters, which one route spells that way. + * + * `GET /repos/{owner}/{repo}/git/trees/{sha}` is the odd one out: it declares `per_page` and ignores `limit` + * entirely, so sending the usual spelling there returns the instance's default page size and no error at all. That + * single word is why this function exists rather than [[window]] being reused. + */ + def perPageWindow(params: PageParams): List[(String, String)] = + List("page" -> params.page.value.toString, "per_page" -> params.size.value.toString) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala index 0429c04..dfcf7a8 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.issues.CommentQuery import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.issues.IssueSearchQuery @@ -24,14 +25,14 @@ import com.worxbend.codeberg4s.paging.PageParams */ private[codeberg4s] object IssueQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always.''' `golden/MANIFEST.md` records that `limit` alone is silently ignored on some Forgejo endpoints - * — `?limit=2` against `/forks` returned all 862 forks, and adding `page=1` made the limit take effect — so sending - * a limit without a page is how a client accidentally pulls an unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of `GET /repos/{owner}/{repo}/issues`, in the order the spec declares them. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala index 5a75eae..272cbd3 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.notifications.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.issues.wire.WireInstant import com.worxbend.codeberg4s.notifications.NotificationQuery import com.worxbend.codeberg4s.paging.PageParams @@ -25,14 +26,14 @@ import com.worxbend.codeberg4s.paging.PageParams */ private[codeberg4s] object NotificationQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always.''' `golden/MANIFEST.md` records that `limit` alone is silently ignored on some Forgejo endpoints - * — `?limit=2` against `/forks` returned all 862 forks, and adding `page=1` made the limit take effect — so sending - * a limit without a page is how a client accidentally pulls an unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of both notification listings, in the order the spec declares them. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationQueries.scala index 9449f79..1e5cb61 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.organizations.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.organizations.OrganizationLabelSort import com.worxbend.codeberg4s.organizations.QuotaSubject import com.worxbend.codeberg4s.paging.PageParams @@ -27,12 +28,12 @@ private[codeberg4s] object OrganizationQueries: /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states and - * `golden/MANIFEST.md` measured: a `limit` sent without a `page` is silently ignored by some Forgejo endpoints, - * which is how a client accidentally pulls an unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The parameters of `GET /orgs/{org}/labels`: an optional ordering, then the window. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueries.scala index a7282c4..f72e37f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.pulls.DiffRequest import com.worxbend.codeberg4s.pulls.PullRequestQuery @@ -21,14 +22,14 @@ import com.worxbend.codeberg4s.pulls.UpdateStyle */ private[codeberg4s] object PullRequestQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always.''' `golden/MANIFEST.md` records that `limit` alone is silently ignored on some Forgejo endpoints - * — `?limit=2` against `/forks` returned all 862 forks, and adding `page=1` made the limit take effect — so sending - * a limit without a page is how a client accidentally pulls an unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of `GET /repos/{owner}/{repo}/pulls`. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/AccessQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/AccessQueries.scala index ea1274e..720d894 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/AccessQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/AccessQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.access.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.repositories.access.DeployKeyQuery @@ -20,14 +21,14 @@ import com.worxbend.codeberg4s.repositories.access.DeployKeyQuery */ private[codeberg4s] object AccessQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of the deploy key listing, in the order the spec declares them. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionQueries.scala index 1169445..a184004 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.actions.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.repositories.actions.ActionRunQuery import com.worxbend.codeberg4s.repositories.actions.ActionTaskQuery @@ -29,14 +30,14 @@ import com.worxbend.codeberg4s.repositories.actions.RunnerVisibility */ private[codeberg4s] object ActionQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of the run listing, in the order the spec declares them. */ def runs(query: ActionRunQuery): List[(String, String)] = diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminQueries.scala index 828dafa..35e549a 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/AdminQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.admin.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.repositories.gitdata.RefName @@ -19,14 +20,14 @@ import java.time.format.DateTimeFormatter */ private[codeberg4s] object AdminQueries: - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The `ref` parameter of the root contents listing. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitDataQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitDataQueries.scala index e2311b8..be17f6c 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitDataQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitDataQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.repositories.gitdata.CommitInclude import com.worxbend.codeberg4s.repositories.gitdata.CommitStatusQuery @@ -20,21 +21,20 @@ private[codeberg4s] object GitDataQueries: /** The `page` and `limit` parameters, as almost every Forgejo listing spells them. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The window of `GET /repos/{owner}/{repo}/git/trees/{sha}`, which spells the size `per_page`. * * '''This endpoint is the odd one out.''' Every other paged route in the library takes `limit`; the tree route - * declares `per_page` and ignores `limit` entirely, so sending the usual spelling here returns the instance's - * default page size and no error. That single word is the reason this function exists rather than [[paging]] being - * reused. + * declares `per_page` and ignores it entirely, which is why the window comes from + * [[com.worxbend.codeberg4s.codec.PagingQuery.perPageWindow]] and not from [[paging]]. * * @param recursive * whether to descend into subtrees; emitted only when `true`, since `false` is the instance's own default */ def treeWindow(params: PageParams, recursive: Boolean): List[(String, String)] = Option.when(recursive)("recursive" -> "true").toList ++ - List("page" -> params.page.value.toString, "per_page" -> params.size.value.toString) + PagingQuery.perPageWindow(params) /** The `stat`, `verification` and `files` parameters of `GET /repos/{owner}/{repo}/git/commits/{sha}`. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/HookQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/HookQueries.scala index 9268c64..af01b55 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/HookQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/HookQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.hooks.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams /** The query strings this group's endpoints send. @@ -11,26 +12,25 @@ import com.worxbend.codeberg4s.paging.PageParams */ private[codeberg4s] object HookQueries: - /** The `page` and `limit` parameters of a fully paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The `page` parameter of the wiki revision listing, which takes no `limit`. * * '''`limit` is deliberately not sent.''' `spec/swagger.v1.json` declares `page` and nothing else for - * `repoGetWikiPageRevisions`, unlike every other paged operation in this group, so the instance chooses the page - * size and the caller's [[com.worxbend.codeberg4s.paging.PageParams.size]] does not reach the wire. Sending an - * undeclared parameter on the chance that Forgejo reads it would be exactly the guesswork `docs/HAZARDS.md` warns - * against; the requested window is still carried onto the returned page, and where the collection ends is decided by - * the `Link` header as it is everywhere else. + * `repoGetWikiPageRevisions`, unlike every other paged operation in this group, which is why the window comes from + * [[com.worxbend.codeberg4s.codec.PagingQuery.pageOnly]] and not from [[paging]]. The requested window is still + * carried onto the returned page, and where the collection ends is decided by the `Link` header as it is everywhere + * else. */ def revisionPaging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString) + PagingQuery.pageOnly(params) /** The `ref` parameter of the webhook test route. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountQueries.scala index 1e41256..90a80ee 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/AccountQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.account.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.users.account.QuotaSubject import com.worxbend.codeberg4s.users.account.RepositoryOrder @@ -25,14 +26,14 @@ private[codeberg4s] object AccountQueries: /** The query parameter `GET /user/quota/check` takes its subject from. */ val SubjectParameter: String = "subject" - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The `order_by` parameter of the repository listing. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala index 4fb7a18..b1feae3 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.social.wire +import com.worxbend.codeberg4s.codec.PagingQuery import com.worxbend.codeberg4s.issues.wire.WireInstant import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.users.social.ActivityFeedQuery @@ -31,14 +32,14 @@ private[codeberg4s] object SocialQueries: /** The wire key of the upper bound of a tracked-time window. */ val BeforeKey: String = "before" - /** The `page` and `limit` parameters for a paged listing. + /** The `page` and `limit` parameters of a paged listing. * - * '''Both, always''', for the reason [[com.worxbend.codeberg4s.issues.wire.IssueQueries.paging]] states: a limit - * sent without a page is silently ignored by some Forgejo endpoints, which is how a client accidentally pulls an - * unbounded collection. + * Both are always sent, and the pair is rendered by [[com.worxbend.codeberg4s.codec.PagingQuery.window]], which + * carries the measurement behind that rule: a `limit` sent without a `page` is silently ignored by some Forgejo + * endpoints, which is how a client accidentally pulls an unbounded collection. */ def paging(params: PageParams): List[(String, String)] = - List("page" -> params.page.value.toString, "limit" -> params.size.value.toString) + PagingQuery.window(params) /** The filters of the activity-feed listing, in the order the spec declares them. * From d57db8d16d75d2149a6090f712a6d9ffd8a30cab Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:16:55 +0300 Subject: [PATCH 18/31] style(domain): reformat the segment-literal sources Scalafmt's import ordering and Scaladoc wrapping had not been applied to these two files when they landed. Running the formatter now keeps the realignment out of a later logic diff. --- .../com/worxbend/codeberg4s/SegmentLiteral.scala | 15 +++++++-------- .../worxbend/codeberg4s/SegmentLiteralSuite.scala | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala b/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala index 6d0546e..18c76df 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/SegmentLiteral.scala @@ -11,8 +11,8 @@ import scala.compiletime.ops.string.Matches * ==Why this exists== * * Almost every identifier in this library is written down as a string literal by the programmer, not computed at run - * time: `Owner("forgejo")`, `BranchName("main")`, `RepoName("codeberg4s")`. A literal is either valid or it is not, and - * which one it is can be decided before the program ever runs. Yet `Owner.from` returns `Either[ValidationError, + * time: `Owner("forgejo")`, `BranchName("main")`, `RepoName("codeberg4s")`. A literal is either valid or it is not, + * and which one it is can be decided before the program ever runs. Yet `Owner.from` returns `Either[ValidationError, * Owner]`, so every one of those literals used to drag a `for` comprehension or an `orFail` helper behind it purely to * discharge a failure that cannot happen. * @@ -23,10 +23,9 @@ import scala.compiletime.ops.string.Matches * * ==How it works, and what it costs== * - * There is no macro here. Each check is a single `inline if` over - * [[scala.compiletime.ops.string.Matches]], which asks the compiler whether a literal '''type''' matches a regular - * expression. That happens during typing, so the whole call folds away to the literal string; nothing of this object - * survives into the bytecode of the call site. + * There is no macro here. Each check is a single `inline if` over [[scala.compiletime.ops.string.Matches]], which asks + * the compiler whether a literal '''type''' matches a regular expression. That happens during typing, so the whole + * call folds away to the literal string; nothing of this object survives into the bytecode of the call site. * * The price is that the rule is written twice — once as the readable `if`/`else` chain in [[PathSegment]], and once as * a regular expression here — which is exactly the duplication [[PathSegment]]'s own Scaladoc warns about. It is @@ -73,7 +72,7 @@ object SegmentLiteral: case Some(_) => inline if constValue[Matches[V, Plain]] then value else error("not a valid " + field + ": " + codeOf(value)) - case None => + case None => error("a " + field + " built this way has to be a string literal; use `.from` for a run-time value") /** Accepts `value` if every `/`-separated part of it can stand alone as one path segment, and fails the compilation @@ -87,5 +86,5 @@ object SegmentLiteral: case Some(_) => inline if constValue[Matches[V, Segmented]] then value else error("not a valid " + field + ": " + codeOf(value)) - case None => + case None => error("a " + field + " built this way has to be a string literal; use `.from` for a run-time value") diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala index 6efabc2..979eb3a 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/SegmentLiteralSuite.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s -import munit.FunSuite - import com.worxbend.codeberg4s.repositories.BranchName +import munit.FunSuite + /** What the compile-time identifier constructors promise. * * Two of these tests are ordinary assertions about accepted literals. The rest are the interesting ones: they use @@ -61,9 +61,9 @@ final class SegmentLiteralSuite extends FunSuite: /** Values chosen to sit on the edges of both spellings of the rule. * - * Every entry is already equal to its own `trim`, because that is the one place the two rules are meant to - * disagree: `PathSegment` trims and the literal check refuses whitespace outright, so a padded value would report a - * difference that is intended rather than a drift. + * Every entry is already equal to its own `trim`, because that is the one place the two rules are meant to disagree: + * `PathSegment` trims and the literal check refuses whitespace outright, so a padded value would report a difference + * that is intended rather than a drift. */ private val Corpus: List[String] = List( "forgejo", From 8b9620be08643275565246ef4f19468f0c77dd01 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:19:56 +0300 Subject: [PATCH 19/31] refactor(client): decode whole-array responses through WireDecode.vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every endpoint that answers with a bare JSON array was spelling out the same three parts by hand: read a `Vector[SomethingDto]`, name a lambda parameter for the decoded vector, and hand `JsonPath.Root` to the DTO's bulk projection so the per-element error paths read as `[0].name` instead of being rooted at a field that does not exist. `WireDecode.vector` now holds that shape in one place, so a list endpoint names only its DTO and its projection. Envelope-shaped responses — where the array sits inside an object and the base path is that object's field, not the root — keep using `WireDecode.of`, and the new helper's scaladoc says so. This is a same-behaviour rewrite: `vector` is defined in terms of `of` with the same root path the call sites passed before, so the decoded values and every reported failure path are unchanged. --- .../codeberg4s/client/WireDecode.scala | 21 ++++++++++++++ .../codeberg4s/issues/IssueDecoders.scala | 22 +++++++------- .../miscellaneous/MiscellaneousApi.scala | 11 +++---- .../notifications/NotificationApi.scala | 5 +--- .../organizations/OrganizationDecoders.scala | 26 ++++++++--------- .../actions/OrganizationActionDecoders.scala | 9 +++--- .../codeberg4s/pulls/PullRequestApi.scala | 14 ++++----- .../access/RepositoryAccessDecoders.scala | 15 ++++------ .../actions/RepositoryActionDecoders.scala | 10 +++---- .../gitdata/GitDataDecoders.scala | 13 ++++----- .../hooks/RepositoryHookDecoders.scala | 10 +++---- .../publishing/PublishingDecoders.scala | 4 +-- .../worxbend/codeberg4s/users/UserApi.scala | 6 ++-- .../users/account/UserAccountDecoders.scala | 29 ++++++++----------- .../users/social/SocialDecoders.scala | 22 +++++++------- 15 files changed, 109 insertions(+), 108 deletions(-) diff --git a/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala b/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala index 73d9ed5..8db33b5 100644 --- a/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala +++ b/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.client +import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.core.ResponseBody @@ -26,3 +27,23 @@ private[codeberg4s] object WireDecode: */ def of[D, A](wire: Decode[D])(toDomain: D => Either[DecodeFailure, A]): Decode[A] = (body: ResponseBody) => wire(body).flatMap(toDomain) + + /** A decoder for a response whose whole body is a JSON array, converted element by element. + * + * This is [[of]] with the one detail every list endpoint would otherwise repeat filled in: the array is the whole + * body, so the base path handed to the DTO's `toDomainAll` is [[com.worxbend.codeberg4s.JsonPath.Root]] and the + * element paths it reports read as `[0].name` rather than something rooted at a field that does not exist. Writing + * that once means a list endpoint names its DTO and its projection and nothing else. + * + * Use [[of]] instead when the array is nested inside an envelope object, because then the base path is that + * envelope's field, not the root. + * + * @param wire + * the codec module's reader for the array of wire DTOs + * @param toDomainAll + * the DTO companion's bulk projection, which takes the base path of the array it is converting + */ + def vector[D, A]( + wire: Decode[Vector[D]] + )(toDomainAll: (JsonPath, Vector[D]) => Either[DecodeFailure, Vector[A]]): Decode[Vector[A]] = + of(wire)(dtos => toDomainAll(JsonPath.Root, dtos)) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala index c0fdcb9..6f9bfa8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala @@ -1,6 +1,5 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode @@ -40,7 +39,7 @@ private[issues] object IssueDecoders: /** A bare array of issue objects — the cross-repository search, the blocks listing and the dependency listing. */ val issues: Decode[Vector[Issue]] = - WireDecode.of(Json.decoder[Vector[IssueDto]])(dtos => IssueDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[IssueDto]])(IssueDto.toDomainAll) /** One comment object on an endpoint that always sends a body — posting a comment, where `201` is the only success. * @@ -65,7 +64,7 @@ private[issues] object IssueDecoders: /** A bare array of comment objects, as the repository-wide comment listing returns it. */ val comments: Decode[Vector[Comment]] = - WireDecode.of(Json.decoder[Vector[CommentDto]])(dtos => CommentDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[CommentDto]])(CommentDto.toDomainAll) /** One label object. */ val label: Decode[Label] = @@ -73,7 +72,7 @@ private[issues] object IssueDecoders: /** A bare array of label objects, as the per-issue label calls return it. */ val labels: Decode[Vector[Label]] = - WireDecode.of(Json.decoder[Vector[LabelDto]])(dtos => LabelDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[LabelDto]])(LabelDto.toDomainAll) /** One milestone object. */ val milestone: Decode[Milestone] = @@ -81,7 +80,7 @@ private[issues] object IssueDecoders: /** A bare array of milestone objects, as the repository's milestone listing returns it. */ val milestones: Decode[Vector[Milestone]] = - WireDecode.of(Json.decoder[Vector[MilestoneDto]])(dtos => MilestoneDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[MilestoneDto]])(MilestoneDto.toDomainAll) /** One attachment object. */ val attachment: Decode[IssueAttachment] = @@ -89,7 +88,7 @@ private[issues] object IssueDecoders: /** A bare array of attachment objects. */ val attachments: Decode[Vector[IssueAttachment]] = - WireDecode.of(Json.decoder[Vector[AttachmentDto]])(dtos => AttachmentDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[AttachmentDto]])(AttachmentDto.toDomainAll) /** One reaction object, as adding a reaction returns it. */ val reaction: Decode[Reaction] = @@ -97,7 +96,7 @@ private[issues] object IssueDecoders: /** A bare array of reaction objects — one element per account per emoji, never a tally. */ val reactions: Decode[Vector[Reaction]] = - WireDecode.of(Json.decoder[Vector[ReactionDto]])(dtos => ReactionDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ReactionDto]])(ReactionDto.toDomainAll) /** The one-key object the deadline endpoint answers. */ val deadline: Decode[IssueDeadline] = @@ -114,8 +113,8 @@ private[issues] object IssueDecoders: * listings. One bad element still fails the page, and reports its position. */ val users: Decode[Vector[User]] = - WireDecode.of(Json.decoder[Vector[UserDto]]): dtos => - WireElements.at(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => + WireElements.at(at, dtos)(_.toDomainAt(_)) /** One tracked-time entry, as adding time returns it. */ val trackedTime: Decode[TrackedTime] = @@ -123,9 +122,8 @@ private[issues] object IssueDecoders: /** A bare array of tracked-time entries. */ val trackedTimes: Decode[Vector[TrackedTime]] = - WireDecode.of(Json.decoder[Vector[TrackedTimeDto]])(dtos => TrackedTimeDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TrackedTimeDto]])(TrackedTimeDto.toDomainAll) /** A bare array of timeline entries. */ val timeline: Decode[Vector[TimelineEvent]] = - WireDecode.of(Json.decoder[Vector[TimelineCommentDto]]): dtos => - TimelineCommentDto.toDomainAll(JsonPath.Root, dtos) + WireDecode.vector(Json.decoder[Vector[TimelineCommentDto]])(TimelineCommentDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala index 9353862..60d2051 100644 --- a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala @@ -2,7 +2,6 @@ package com.worxbend.codeberg4s.miscellaneous import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline @@ -591,19 +590,17 @@ object MiscellaneousApi: * [[com.worxbend.codeberg4s.miscellaneous.wire.TemplateNamesDto]]. */ private val TemplateNamesDecoder: Decode[Vector[TemplateName]] = - WireDecode.of(Json.decoder[Vector[String]])(names => TemplateNamesDto.toDomainAll(JsonPath.Root, names)) + WireDecode.vector(Json.decoder[Vector[String]])(TemplateNamesDto.toDomainAll) private val GitignoreTemplateDecoder: Decode[GitignoreTemplate] = WireDecode.of(Json.decoder[GitignoreTemplateDto])(_.toDomain) private val TemplateLabelsDecoder: Decode[Vector[TemplateLabel]] = - WireDecode.of(Json.decoder[Vector[TemplateLabelDto]])(dtos => TemplateLabelDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TemplateLabelDto]])(TemplateLabelDto.toDomainAll) private val LicenseTemplatesDecoder: Decode[Vector[LicenseTemplateSummary]] = - WireDecode.of(Json.decoder[Vector[LicenseTemplateSummaryDto]]): dtos => - LicenseTemplateSummaryDto.toDomainAll(JsonPath.Root, dtos) - - private val LicenseTemplateDecoder: Decode[LicenseTemplate] = + WireDecode.vector(Json.decoder[Vector[LicenseTemplateSummaryDto]])(LicenseTemplateSummaryDto.toDomainAll) + private val LicenseTemplateDecoder: Decode[LicenseTemplate] = WireDecode.of(Json.decoder[LicenseTemplateDto])(_.toDomain) private val NodeInfoDecoder: Decode[NodeInfo] = diff --git a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala index fbe34b7..738ad75 100644 --- a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala @@ -2,7 +2,6 @@ package com.worxbend.codeberg4s.notifications import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode @@ -319,9 +318,7 @@ object NotificationApi: WireDecode.of(Json.decoder[NotificationThreadDto])(_.toDomain) private val ThreadsDecoder: Decode[Vector[NotificationThread]] = - WireDecode.of(Json.decoder[Vector[NotificationThreadDto]])(dtos => - NotificationThreadDto.toDomainAll(JsonPath.Root, dtos) - ) + WireDecode.vector(Json.decoder[Vector[NotificationThreadDto]])(NotificationThreadDto.toDomainAll) private val CountDecoder: Decode[UnreadCount] = WireDecode.of(Json.decoder[NotificationCountDto])(_.toDomain) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala index 330b16f..bee9b77 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala @@ -60,7 +60,7 @@ private[organizations] object OrganizationDecoders: /** A bare array of organisation objects, as `GET /orgs` and `GET /users/{username}/orgs` return it. */ val organizations: Decode[Vector[Organization]] = - WireDecode.of(Json.decoder[Vector[OrganizationDto]])(dtos => OrganizationDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[OrganizationDto]])(OrganizationDto.toDomainAll) /** One team object, as `GET /teams/{id}` returns it. */ val team: Decode[Team] = @@ -68,17 +68,17 @@ private[organizations] object OrganizationDecoders: /** A bare array of team objects, as `GET /orgs/{org}/teams` returns it. */ val teams: Decode[Vector[Team]] = - WireDecode.of(Json.decoder[Vector[TeamDto]])(dtos => TeamDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TeamDto]])(TeamDto.toDomainAll) /** A bare array of user objects, as the member and team-member listings return it. */ val users: Decode[Vector[User]] = - WireDecode.of(Json.decoder[Vector[UserDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of repository objects, as the organisation and team repository listings return it. */ val repositories: Decode[Vector[Repository]] = - WireDecode.of(Json.decoder[Vector[RepositoryDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** One user object, as `GET /teams/{id}/members/{username}` returns it. */ val user: Decode[User] = @@ -99,7 +99,7 @@ private[organizations] object OrganizationDecoders: /** A bare array of webhook objects, as `GET /orgs/{org}/hooks` returns it. */ val webhooks: Decode[Vector[Webhook]] = - WireDecode.of(Json.decoder[Vector[WebhookDto]])(dtos => WebhookDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[WebhookDto]])(WebhookDto.toDomainAll) /** One label object, as the organisation label routes return it. */ val label: Decode[Label] = @@ -107,15 +107,15 @@ private[organizations] object OrganizationDecoders: /** A bare array of label objects; `golden/organization/org-labels-list.json` is a capture of exactly this. */ val labels: Decode[Vector[Label]] = - WireDecode.of(Json.decoder[Vector[LabelDto]])(dtos => LabelDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[LabelDto]])(LabelDto.toDomainAll) /** A bare array of activity entries, as the organisation and team feeds return them. */ val activities: Decode[Vector[RepositoryActivity]] = - WireDecode.of(Json.decoder[Vector[ActivityDto]])(dtos => ActivityDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActivityDto]])(ActivityDto.toDomainAll) /** A bare array of block records, as `GET /orgs/{org}/list_blocked` returns it. */ val blockedUsers: Decode[Vector[BlockedUser]] = - WireDecode.of(Json.decoder[Vector[BlockedUserDto]])(dtos => BlockedUserDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[BlockedUserDto]])(BlockedUserDto.toDomainAll) /** The five effective permission flags, as `GET /users/{username}/orgs/{org}/permissions` returns them. */ val permissions: Decode[OrganizationPermissions] = @@ -136,12 +136,12 @@ private[organizations] object OrganizationDecoders: /** A bare array of artifact usage entries. */ val quotaArtifacts: Decode[Vector[QuotaArtifact]] = - WireDecode.of(Json.decoder[Vector[QuotaArtifactDto]])(dtos => QuotaArtifactDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[QuotaArtifactDto]])(QuotaArtifactDto.toDomainAll) /** A bare array of attachment usage entries. */ val quotaAttachments: Decode[Vector[QuotaAttachment]] = - WireDecode.of(Json.decoder[Vector[QuotaAttachmentDto]])(dtos => QuotaAttachmentDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[QuotaAttachmentDto]])(QuotaAttachmentDto.toDomainAll) /** A bare array of package usage entries. */ val quotaPackages: Decode[Vector[QuotaPackage]] = - WireDecode.of(Json.decoder[Vector[QuotaPackageDto]])(dtos => QuotaPackageDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[QuotaPackageDto]])(QuotaPackageDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala index b49683a..d0c9913 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala @@ -1,6 +1,5 @@ package com.worxbend.codeberg4s.organizations.actions -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode @@ -48,7 +47,7 @@ private[actions] object OrganizationActionDecoders: /** A bare array of runner objects, as the organisation's runner listing returns it. */ val runners: Decode[Vector[ActionRunner]] = - WireDecode.of(Json.decoder[Vector[ActionRunnerDto]])(dtos => ActionRunnerDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunnerDto]])(ActionRunnerDto.toDomainAll) /** The `{id, uuid, token}` object a runner registration returns, whose `token` is a live credential. * @@ -67,11 +66,11 @@ private[actions] object OrganizationActionDecoders: /** A bare array of job objects, as the runner job search returns it. */ val jobs: Decode[Vector[ActionRunJob]] = - WireDecode.of(Json.decoder[Vector[ActionRunJobDto]])(dtos => ActionRunJobDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunJobDto]])(ActionRunJobDto.toDomainAll) /** A bare array of secret objects — names and timestamps, never values. */ val secrets: Decode[Vector[ActionSecret]] = - WireDecode.of(Json.decoder[Vector[ActionSecretDto]])(dtos => ActionSecretDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionSecretDto]])(ActionSecretDto.toDomainAll) /** One variable object. */ val variable: Decode[ActionVariable] = @@ -79,4 +78,4 @@ private[actions] object OrganizationActionDecoders: /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = - WireDecode.of(Json.decoder[Vector[ActionVariableDto]])(dtos => ActionVariableDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionVariableDto]])(ActionVariableDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index 89b24b2..966a937 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -2,7 +2,6 @@ package com.worxbend.codeberg4s.pulls import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.HttpMethod -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode @@ -1417,24 +1416,23 @@ object PullRequestApi: WireDecode.of(Json.decoder[PullRequestDto])(_.toDomain) private val PullsDecoder: Decode[Vector[PullRequest]] = - WireDecode.of(Json.decoder[Vector[PullRequestDto]])(dtos => PullRequestDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[PullRequestDto]])(PullRequestDto.toDomainAll) private val ReviewDecoder: Decode[Review] = WireDecode.of(Json.decoder[ReviewDto])(_.toDomain) private val ReviewsDecoder: Decode[Vector[Review]] = - WireDecode.of(Json.decoder[Vector[ReviewDto]])(dtos => ReviewDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ReviewDto]])(ReviewDto.toDomainAll) private val ReviewCommentDecoder: Decode[ReviewComment] = WireDecode.of(Json.decoder[ReviewCommentDto])(_.toDomain) private val ReviewCommentsDecoder: Decode[Vector[ReviewComment]] = - WireDecode.of(Json.decoder[Vector[ReviewCommentDto]])(dtos => ReviewCommentDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ReviewCommentDto]])(ReviewCommentDto.toDomainAll) private val CommitsDecoder: Decode[Vector[Commit]] = - WireDecode.of(Json.decoder[Vector[CommitDto]])(dtos => - Elements.convert(JsonPath.Root, dtos)((dto, path) => dto.toDomainAt(path)) - ) + WireDecode.vector(Json.decoder[Vector[CommitDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) private val FilesDecoder: Decode[Vector[ChangedFile]] = - WireDecode.of(Json.decoder[Vector[ChangedFileDto]])(dtos => ChangedFileDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ChangedFileDto]])(ChangedFileDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala index b8c59ff..6b9b046 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala @@ -1,6 +1,5 @@ package com.worxbend.codeberg4s.repositories.access -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode @@ -42,9 +41,7 @@ private[access] object RepositoryAccessDecoders: /** A bare array of branch protection rules, as the unpaged listing returns it. */ val branchProtections: Decode[Vector[BranchProtection]] = - WireDecode.of(Json.decoder[Vector[BranchProtectionDto]])(dtos => - BranchProtectionDto.toDomainAll(JsonPath.Root, dtos) - ) + WireDecode.vector(Json.decoder[Vector[BranchProtectionDto]])(BranchProtectionDto.toDomainAll) /** One tag protection rule. */ val tagProtection: Decode[TagProtection] = @@ -52,7 +49,7 @@ private[access] object RepositoryAccessDecoders: /** A bare array of tag protection rules. */ val tagProtections: Decode[Vector[TagProtection]] = - WireDecode.of(Json.decoder[Vector[TagProtectionDto]])(dtos => TagProtectionDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TagProtectionDto]])(TagProtectionDto.toDomainAll) /** A bare array of users, which is what the collaborator listing answers. * @@ -62,8 +59,8 @@ private[access] object RepositoryAccessDecoders: * its position. */ val collaborators: Decode[Vector[User]] = - WireDecode.of(Json.decoder[Vector[UserDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** The `{permission, role_name, user}` object the collaborator permission endpoint answers. */ val collaboratorAccess: Decode[CollaboratorAccess] = @@ -75,7 +72,7 @@ private[access] object RepositoryAccessDecoders: /** A bare array of deploy keys. */ val deployKeys: Decode[Vector[DeployKey]] = - WireDecode.of(Json.decoder[Vector[DeployKeyDto]])(dtos => DeployKeyDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[DeployKeyDto]])(DeployKeyDto.toDomainAll) /** One team, which is what the team check answers rather than the `204` its siblings answer. */ val team: Decode[Team] = @@ -83,4 +80,4 @@ private[access] object RepositoryAccessDecoders: /** A bare array of teams, as `TeamListWithoutPagination` returns it. */ val teams: Decode[Vector[Team]] = - WireDecode.of(Json.decoder[Vector[TeamDto]])(dtos => TeamDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TeamDto]])(TeamDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala index acbc966..2a3699a 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala @@ -45,7 +45,7 @@ private[actions] object RepositoryActionDecoders: /** A bare array of artifact objects, as both artifact listings return it. */ val artifacts: Decode[Vector[ActionArtifact]] = - WireDecode.of(Json.decoder[Vector[ActionArtifactDto]])(dtos => ActionArtifactDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionArtifactDto]])(ActionArtifactDto.toDomainAll) /** One run object. */ val run: Decode[ActionRun] = @@ -58,7 +58,7 @@ private[actions] object RepositoryActionDecoders: /** A bare array of job objects, as both the run's job listing and the runner job search return it. */ val jobs: Decode[Vector[ActionRunJob]] = - WireDecode.of(Json.decoder[Vector[ActionRunJobDto]])(dtos => ActionRunJobDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunJobDto]])(ActionRunJobDto.toDomainAll) /** The same envelope as [[runs]], carrying tasks. The key is `workflow_runs` there too; see the envelope's note. */ val tasks: Decode[Vector[ActionTask]] = @@ -71,7 +71,7 @@ private[actions] object RepositoryActionDecoders: /** A bare array of runner objects. */ val runners: Decode[Vector[ActionRunner]] = - WireDecode.of(Json.decoder[Vector[ActionRunnerDto]])(dtos => ActionRunnerDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunnerDto]])(ActionRunnerDto.toDomainAll) /** The `{id, uuid, token}` object a runner registration returns, whose `token` is a live credential. * @@ -90,7 +90,7 @@ private[actions] object RepositoryActionDecoders: /** A bare array of secret objects — names and timestamps, never values. */ val secrets: Decode[Vector[ActionSecret]] = - WireDecode.of(Json.decoder[Vector[ActionSecretDto]])(dtos => ActionSecretDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionSecretDto]])(ActionSecretDto.toDomainAll) /** One variable object. */ val variable: Decode[ActionVariable] = @@ -98,7 +98,7 @@ private[actions] object RepositoryActionDecoders: /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = - WireDecode.of(Json.decoder[Vector[ActionVariableDto]])(dtos => ActionVariableDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionVariableDto]])(ActionVariableDto.toDomainAll) /** The dispatch acknowledgement, which is present only when the request asked for it. * diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala index 3731779..f62567e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala @@ -1,6 +1,5 @@ package com.worxbend.codeberg4s.repositories.gitdata -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode @@ -45,8 +44,8 @@ private[gitdata] object GitDataDecoders: /** A bare array of `GitBlob` objects, as the multi-blob read returns it. */ val blobs: Decode[Vector[GitBlob]] = - WireDecode.of(Json.decoder[Vector[GitBlobDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[GitBlobDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** The `{"sha", "tree", …}` envelope, unwrapped to the entries it carries. */ val treeEntries: Decode[Vector[GitTreeEntry]] = @@ -62,8 +61,8 @@ private[gitdata] object GitDataDecoders: /** A bare array of `Reference` objects. */ val references: Decode[Vector[GitReference]] = - WireDecode.of(Json.decoder[Vector[ReferenceDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[ReferenceDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** One `AnnotatedTag` object. */ val annotatedTag: Decode[AnnotatedTag] = @@ -75,8 +74,8 @@ private[gitdata] object GitDataDecoders: /** A bare array of `CommitStatus` objects. */ val commitStatuses: Decode[Vector[CommitStatus]] = - WireDecode.of(Json.decoder[Vector[CommitStatusDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[CommitStatusDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** One `PullRequest` object, reusing the pull-request wave's model rather than a reduced copy of it. */ val pullRequest: Decode[PullRequest] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala index 65142d4..e231625 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala @@ -40,7 +40,7 @@ private[hooks] object RepositoryHookDecoders: /** A bare array of webhook objects, as the hook listing returns it. */ val webhooks: Decode[Vector[Webhook]] = - WireDecode.of(Json.decoder[Vector[WebhookDto]])(dtos => WebhookDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[WebhookDto]])(WebhookDto.toDomainAll) /** One Git hook object. */ val gitHook: Decode[GitHook] = @@ -48,11 +48,11 @@ private[hooks] object RepositoryHookDecoders: /** A bare array of Git hook objects. */ val gitHooks: Decode[Vector[GitHook]] = - WireDecode.of(Json.decoder[Vector[GitHookDto]])(dtos => GitHookDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[GitHookDto]])(GitHookDto.toDomainAll) /** A bare array of flag names, validated as path segments on the way into the domain. */ val flags: Decode[Vector[RepositoryFlag]] = - WireDecode.of(Json.decoder[Vector[String]])(values => RepositoryFlagWire.toDomainAll(JsonPath.Root, values)) + WireDecode.vector(Json.decoder[Vector[String]])(RepositoryFlagWire.toDomainAll) /** One wiki page, content included. */ val wikiPage: Decode[WikiPage] = @@ -60,7 +60,7 @@ private[hooks] object RepositoryHookDecoders: /** A bare array of wiki page listing entries — metadata only, no content. */ val wikiPages: Decode[Vector[WikiPageMeta]] = - WireDecode.of(Json.decoder[Vector[WikiPageMetaDto]])(dtos => WikiPageMetaDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[WikiPageMetaDto]])(WikiPageMetaDto.toDomainAll) /** The `{"commits", "count"}` envelope the revision listing returns, unwrapped to its revisions. */ val wikiRevisions: Decode[Vector[WikiCommit]] = @@ -77,4 +77,4 @@ private[hooks] object RepositoryHookDecoders: /** A bare array of issue template objects. */ val issueTemplates: Decode[Vector[IssueTemplate]] = - WireDecode.of(Json.decoder[Vector[IssueTemplateDto]])(dtos => IssueTemplateDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[IssueTemplateDto]])(IssueTemplateDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala index 010d460..813453f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala @@ -47,5 +47,5 @@ private[publishing] object PublishingDecoders: * at `$[3].name` rather than at `$`. */ val assets: Decode[Vector[ReleaseAsset]] = - WireDecode.of(Json.decoder[Vector[ReleaseAssetDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) + WireDecode.vector(Json.decoder[Vector[ReleaseAssetDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala index c5a2557..ebfb821 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala @@ -324,17 +324,17 @@ object UserApi: WireDecode.of(Json.decoder[UserDto])(_.toDomain) private val UserListDecoder: Decode[Vector[User]] = - WireDecode.of(Json.decoder[Vector[UserDto]])(dtos => each(JsonPath.Root, dtos)(_.toDomainAt(_))) + WireDecode.vector(Json.decoder[Vector[UserDto]])(each(_, _)(_.toDomainAt(_))) private val UserSearchDecoder: Decode[Vector[User]] = WireDecode.of(Json.decoder[SearchEnvelopeDto[UserDto]]): envelope => each(JsonPath.Root.field("data"), envelope.data)(_.toDomainAt(_)) private val RepositoryListDecoder: Decode[Vector[Repository]] = - WireDecode.of(Json.decoder[Vector[RepositoryDto]])(dtos => each(JsonPath.Root, dtos)(_.toDomainAt(_))) + WireDecode.vector(Json.decoder[Vector[RepositoryDto]])(each(_, _)(_.toDomainAt(_))) private val PublicKeyListDecoder: Decode[Vector[PublicKey]] = - WireDecode.of(Json.decoder[Vector[PublicKeyDto]])(dtos => each(JsonPath.Root, dtos)(_.toDomainAt(_))) + WireDecode.vector(Json.decoder[Vector[PublicKeyDto]])(each(_, _)(_.toDomainAt(_))) /** Converts every element of a decoded list, stopping at the first element that will not convert. * diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala index e89b282..c4afc31 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala @@ -1,6 +1,5 @@ package com.worxbend.codeberg4s.users.account -import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode @@ -70,12 +69,11 @@ private[account] object UserAccountDecoders: /** A bare array of OAuth2 application objects, as the listing returns it — never with a secret in it. */ val applications: Decode[Vector[OAuth2Application]] = - WireDecode.of(Json.decoder[Vector[OAuth2ApplicationDto]]): dtos => - OAuth2ApplicationDto.toDomainAll(JsonPath.Root, dtos) + WireDecode.vector(Json.decoder[Vector[OAuth2ApplicationDto]])(OAuth2ApplicationDto.toDomainAll) /** A bare array of email objects, which is what both the listing and the `201` of an add return. */ val emails: Decode[Vector[Email]] = - WireDecode.of(Json.decoder[Vector[EmailDto]])(dtos => EmailDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[EmailDto]])(EmailDto.toDomainAll) /** The account's settings object, returned by both the read and the update. */ val settings: Decode[UserSettings] = @@ -87,18 +85,15 @@ private[account] object UserAccountDecoders: /** A bare array of quota-counting artifacts. */ val quotaArtifacts: Decode[Vector[QuotaUsedArtifact]] = - WireDecode.of(Json.decoder[Vector[QuotaUsedArtifactDto]]): dtos => - QuotaUsedArtifactDto.toDomainAll(JsonPath.Root, dtos) + WireDecode.vector(Json.decoder[Vector[QuotaUsedArtifactDto]])(QuotaUsedArtifactDto.toDomainAll) /** A bare array of quota-counting attachments. */ val quotaAttachments: Decode[Vector[QuotaUsedAttachment]] = - WireDecode.of(Json.decoder[Vector[QuotaUsedAttachmentDto]]): dtos => - QuotaUsedAttachmentDto.toDomainAll(JsonPath.Root, dtos) + WireDecode.vector(Json.decoder[Vector[QuotaUsedAttachmentDto]])(QuotaUsedAttachmentDto.toDomainAll) /** A bare array of quota-counting package versions. */ val quotaPackages: Decode[Vector[QuotaUsedPackage]] = - WireDecode.of(Json.decoder[Vector[QuotaUsedPackageDto]]): dtos => - QuotaUsedPackageDto.toDomainAll(JsonPath.Root, dtos) + WireDecode.vector(Json.decoder[Vector[QuotaUsedPackageDto]])(QuotaUsedPackageDto.toDomainAll) /** The bare JSON boolean `GET /user/quota/check` answers. * @@ -116,12 +111,12 @@ private[account] object UserAccountDecoders: /** A bare array of repository objects, as the account's repository listing returns it. */ val repositories: Decode[Vector[Repository]] = - WireDecode.of(Json.decoder[Vector[RepositoryDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, path) => dto.toDomainAt(path)) + WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of team objects, as the account's team listing returns it. */ val teams: Decode[Vector[Team]] = - WireDecode.of(Json.decoder[Vector[TeamDto]])(dtos => TeamDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TeamDto]])(TeamDto.toDomainAll) /** One runner object. */ val runner: Decode[ActionRunner] = @@ -129,11 +124,11 @@ private[account] object UserAccountDecoders: /** A bare array of runner objects. */ val runners: Decode[Vector[ActionRunner]] = - WireDecode.of(Json.decoder[Vector[ActionRunnerDto]])(dtos => ActionRunnerDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunnerDto]])(ActionRunnerDto.toDomainAll) /** A bare array of job objects, as the runner job search returns it. */ val jobs: Decode[Vector[ActionRunJob]] = - WireDecode.of(Json.decoder[Vector[ActionRunJobDto]])(dtos => ActionRunJobDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionRunJobDto]])(ActionRunJobDto.toDomainAll) /** The `{id, uuid, token}` object a runner registration returns, whose `token` is a live credential. * @@ -156,7 +151,7 @@ private[account] object UserAccountDecoders: /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = - WireDecode.of(Json.decoder[Vector[ActionVariableDto]])(dtos => ActionVariableDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActionVariableDto]])(ActionVariableDto.toDomainAll) /** One webhook object. */ val webhook: Decode[Webhook] = @@ -164,4 +159,4 @@ private[account] object UserAccountDecoders: /** A bare array of webhook objects, as the account's hook listing returns it. */ val webhooks: Decode[Vector[Webhook]] = - WireDecode.of(Json.decoder[Vector[WebhookDto]])(dtos => WebhookDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[WebhookDto]])(WebhookDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala index 424825f..592d995 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala @@ -49,21 +49,21 @@ private[social] object SocialDecoders: /** A bare array of user objects, as every follower and following listing returns it. */ val users: Decode[Vector[User]] = - WireDecode.of(Json.decoder[Vector[UserDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, path) => dto.toDomainAt(path)) + WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of repository objects, as the starred and watched listings return them. */ val repositories: Decode[Vector[Repository]] = - WireDecode.of(Json.decoder[Vector[RepositoryDto]]): dtos => - Elements.convert(JsonPath.Root, dtos)((dto, path) => dto.toDomainAt(path)) + WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => + Elements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of block entries. */ val blockedUsers: Decode[Vector[BlockedUser]] = - WireDecode.of(Json.decoder[Vector[BlockedUserDto]])(dtos => BlockedUserDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[BlockedUserDto]])(BlockedUserDto.toDomainAll) /** A bare array of running stopwatches. */ val stopWatches: Decode[Vector[StopWatch]] = - WireDecode.of(Json.decoder[Vector[StopWatchDto]])(dtos => StopWatchDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[StopWatchDto]])(StopWatchDto.toDomainAll) /** A bare array of tracked-time entries, read by the model `client.issues` already uses. * @@ -72,7 +72,7 @@ private[social] object SocialDecoders: * a second endpoint the mistake to avoid. */ val trackedTimes: Decode[Vector[TrackedTime]] = - WireDecode.of(Json.decoder[Vector[TrackedTimeDto]])(dtos => TrackedTimeDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[TrackedTimeDto]])(TrackedTimeDto.toDomainAll) /** A bare array of activity entries, read by the model `client.repos.admin` already uses. * @@ -82,11 +82,11 @@ private[social] object SocialDecoders: * second copy of thirteen fields. */ val activities: Decode[Vector[RepositoryActivity]] = - WireDecode.of(Json.decoder[Vector[ActivityDto]])(dtos => ActivityDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[ActivityDto]])(ActivityDto.toDomainAll) /** A bare array of heatmap buckets. Not paged — the endpoint takes no `page` or `limit`. */ val heatmap: Decode[Vector[HeatmapEntry]] = - WireDecode.of(Json.decoder[Vector[HeatmapEntryDto]])(dtos => HeatmapEntryDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[HeatmapEntryDto]])(HeatmapEntryDto.toDomainAll) /** One GPG key object. */ val gpgKey: Decode[GpgKey] = @@ -94,7 +94,7 @@ private[social] object SocialDecoders: /** A bare array of GPG key objects. */ val gpgKeys: Decode[Vector[GpgKey]] = - WireDecode.of(Json.decoder[Vector[GpgKeyDto]])(dtos => GpgKeyDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[GpgKeyDto]])(GpgKeyDto.toDomainAll) /** One SSH public key object, as `POST /user/keys` and `GET /user/keys/{id}` return it. */ val publicKey: Decode[PublicKey] = @@ -102,7 +102,7 @@ private[social] object SocialDecoders: /** A bare array of access-token objects, with the credential field dropped unconditionally. */ val accessTokens: Decode[Vector[AccessToken]] = - WireDecode.of(Json.decoder[Vector[AccessTokenDto]])(dtos => AccessTokenDto.toDomainAll(JsonPath.Root, dtos)) + WireDecode.vector(Json.decoder[Vector[AccessTokenDto]])(AccessTokenDto.toDomainAll) /** The `201` of a token creation — the one decoder in this library that yields a usable personal access token. * From 223a4adc545a3658ba0c385969eed008c17c145c Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:21:07 +0300 Subject: [PATCH 20/31] refactor(client): rename WireDecode.of to WireDecode.single MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `of` said nothing about which of the two decoding shapes it built, which mattered once `vector` arrived beside it: a reader scanning a list of decoders could not tell from the name whether a given one read one JSON document or an array. `single` and `vector` now read as the pair they are, and the scaladoc spells out that "single" describes the wire side — one JSON value, one projection — so an envelope object that happens to carry a list still belongs to `single`. `WireDecode` is `private[codeberg4s]`, so this is not a breaking change: no published name moves and library users have nothing to migrate. CONTRIBUTING's walkthrough of adding an operation is updated to name both helpers. --- CONTRIBUTING.md | 5 +++-- .../com/worxbend/codeberg4s/VersionApi.scala | 2 +- .../codeberg4s/client/WireDecode.scala | 20 ++++++++++------- .../codeberg4s/issues/IssueDecoders.scala | 18 +++++++-------- .../miscellaneous/MiscellaneousApi.scala | 16 +++++++------- .../notifications/NotificationApi.scala | 4 ++-- .../organizations/OrganizationDecoders.scala | 18 +++++++-------- .../actions/OrganizationActionDecoders.scala | 8 +++---- .../codeberg4s/pulls/PullRequestApi.scala | 6 ++--- .../repositories/RepositoryDecoders.scala | 14 ++++++------ .../access/RepositoryAccessDecoders.scala | 10 ++++----- .../actions/RepositoryActionDecoders.scala | 18 +++++++-------- .../admin/RepositoryAdminDecoders.scala | 22 +++++++++---------- .../gitdata/GitDataDecoders.scala | 20 ++++++++--------- .../hooks/RepositoryHookDecoders.scala | 12 +++++----- .../publishing/PublishingDecoders.scala | 4 ++-- .../worxbend/codeberg4s/users/UserApi.scala | 4 ++-- .../users/account/UserAccountDecoders.scala | 18 +++++++-------- .../users/social/SocialDecoders.scala | 6 ++--- 19 files changed, 115 insertions(+), 110 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f10fc3..3d7c853 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -290,8 +290,9 @@ the fixtures is that your idea was wrong twice already. - a stable `Operation` id — the string that lands in every failure's `CallContext`, which callers alert on, so it does not change afterwards; - a `CodebergRequest` with the method, path segments, query and headers; -- a `Decode` built from the DTO, typically - `WireDecode.of(Json.decoder[FooDto])(_.toDomain)`; +- a `Decode` built from the DTO — `WireDecode.single(Json.decoder[FooDto])(_.toDomain)` + for one object, or `WireDecode.vector(Json.decoder[Vector[FooDto]])(FooDto.toDomainAll)` + when the whole body is an array; - a method calling `pipeline.call(request, eligibility)`; - the same method on the group's `Attempt` class, as `exec.attempt(rail.method(...))` — **derived**, never reimplemented, so the diff --git a/modules/client/src/com/worxbend/codeberg4s/VersionApi.scala b/modules/client/src/com/worxbend/codeberg4s/VersionApi.scala index 18fc375..94452de 100644 --- a/modules/client/src/com/worxbend/codeberg4s/VersionApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/VersionApi.scala @@ -63,4 +63,4 @@ object VersionApi: ) private val Decoder: Decode[ServerVersion] = - WireDecode.of(Json.decoder[ServerVersionDto])(_.toDomain) + WireDecode.single(Json.decoder[ServerVersionDto])(_.toDomain) diff --git a/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala b/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala index 8db33b5..efa6ef4 100644 --- a/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala +++ b/modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala @@ -18,24 +18,28 @@ import com.worxbend.codeberg4s.core.ResponseBody */ private[codeberg4s] object WireDecode: - /** A decoder that reads `D` from the body and converts it, stopping at the first failure. + /** A decoder that reads one wire document as `D` and converts it, stopping at the first failure. + * + * "Single" describes the wire side, not the domain side: the body is one JSON value handed to one projection. That + * covers the endpoints answering with a single object, and equally the envelope-shaped ones whose one object carries + * a list, because there the projection — not this helper — decides the base path of the nested array. * * @param wire * the codec module's reader for the wire DTO * @param toDomain * the DTO's own projection, which reports the JSON path of whatever the domain required and did not get */ - def of[D, A](wire: Decode[D])(toDomain: D => Either[DecodeFailure, A]): Decode[A] = + def single[D, A](wire: Decode[D])(toDomain: D => Either[DecodeFailure, A]): Decode[A] = (body: ResponseBody) => wire(body).flatMap(toDomain) /** A decoder for a response whose whole body is a JSON array, converted element by element. * - * This is [[of]] with the one detail every list endpoint would otherwise repeat filled in: the array is the whole - * body, so the base path handed to the DTO's `toDomainAll` is [[com.worxbend.codeberg4s.JsonPath.Root]] and the - * element paths it reports read as `[0].name` rather than something rooted at a field that does not exist. Writing - * that once means a list endpoint names its DTO and its projection and nothing else. + * This is [[single]] with the one detail every list endpoint would otherwise repeat filled in: the array is the + * whole body, so the base path handed to the DTO's `toDomainAll` is [[com.worxbend.codeberg4s.JsonPath.Root]] and + * the element paths it reports read as `[0].name` rather than something rooted at a field that does not exist. + * Writing that once means a list endpoint names its DTO and its projection and nothing else. * - * Use [[of]] instead when the array is nested inside an envelope object, because then the base path is that + * Use [[single]] instead when the array is nested inside an envelope object, because then the base path is that * envelope's field, not the root. * * @param wire @@ -46,4 +50,4 @@ private[codeberg4s] object WireDecode: def vector[D, A]( wire: Decode[Vector[D]] )(toDomainAll: (JsonPath, Vector[D]) => Either[DecodeFailure, Vector[A]]): Decode[Vector[A]] = - of(wire)(dtos => toDomainAll(JsonPath.Root, dtos)) + single(wire)(dtos => toDomainAll(JsonPath.Root, dtos)) diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala index 6f9bfa8..1e515e3 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala @@ -35,7 +35,7 @@ private[issues] object IssueDecoders: /** One issue object, as the blocking and dependency writes return it. */ val issue: Decode[Issue] = - WireDecode.of(Json.decoder[IssueDto])(_.toDomain) + WireDecode.single(Json.decoder[IssueDto])(_.toDomain) /** A bare array of issue objects — the cross-repository search, the blocks listing and the dependency listing. */ val issues: Decode[Vector[Issue]] = @@ -47,7 +47,7 @@ private[issues] object IssueDecoders: * blank body is a malformed response and is reported as one rather than being read as "no comment". */ val presentComment: Decode[Comment] = - WireDecode.of(Json.decoder[CommentDto])(_.toDomain) + WireDecode.single(Json.decoder[CommentDto])(_.toDomain) /** One comment object, on the endpoints where an empty body is also a success. * @@ -68,7 +68,7 @@ private[issues] object IssueDecoders: /** One label object. */ val label: Decode[Label] = - WireDecode.of(Json.decoder[LabelDto])(_.toDomain) + WireDecode.single(Json.decoder[LabelDto])(_.toDomain) /** A bare array of label objects, as the per-issue label calls return it. */ val labels: Decode[Vector[Label]] = @@ -76,7 +76,7 @@ private[issues] object IssueDecoders: /** One milestone object. */ val milestone: Decode[Milestone] = - WireDecode.of(Json.decoder[MilestoneDto])(_.toDomain) + WireDecode.single(Json.decoder[MilestoneDto])(_.toDomain) /** A bare array of milestone objects, as the repository's milestone listing returns it. */ val milestones: Decode[Vector[Milestone]] = @@ -84,7 +84,7 @@ private[issues] object IssueDecoders: /** One attachment object. */ val attachment: Decode[IssueAttachment] = - WireDecode.of(Json.decoder[AttachmentDto])(_.toDomain) + WireDecode.single(Json.decoder[AttachmentDto])(_.toDomain) /** A bare array of attachment objects. */ val attachments: Decode[Vector[IssueAttachment]] = @@ -92,7 +92,7 @@ private[issues] object IssueDecoders: /** One reaction object, as adding a reaction returns it. */ val reaction: Decode[Reaction] = - WireDecode.of(Json.decoder[ReactionDto])(_.toDomain) + WireDecode.single(Json.decoder[ReactionDto])(_.toDomain) /** A bare array of reaction objects — one element per account per emoji, never a tally. */ val reactions: Decode[Vector[Reaction]] = @@ -100,11 +100,11 @@ private[issues] object IssueDecoders: /** The one-key object the deadline endpoint answers. */ val deadline: Decode[IssueDeadline] = - WireDecode.of(Json.decoder[IssueDeadlineDto])(_.toDomain) + WireDecode.single(Json.decoder[IssueDeadlineDto])(_.toDomain) /** The `WatchInfo` object the subscription check answers. */ val subscription: Decode[IssueSubscription] = - WireDecode.of(Json.decoder[IssueSubscriptionDto])(_.toDomain) + WireDecode.single(Json.decoder[IssueSubscriptionDto])(_.toDomain) /** A bare array of user objects, as the subscriber listing returns it. * @@ -118,7 +118,7 @@ private[issues] object IssueDecoders: /** One tracked-time entry, as adding time returns it. */ val trackedTime: Decode[TrackedTime] = - WireDecode.of(Json.decoder[TrackedTimeDto])(_.toDomain) + WireDecode.single(Json.decoder[TrackedTimeDto])(_.toDomain) /** A bare array of tracked-time entries. */ val trackedTimes: Decode[Vector[TrackedTime]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala index 60d2051..efe64a3 100644 --- a/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApi.scala @@ -566,13 +566,13 @@ object MiscellaneousApi: read(ActionsRunOperation, List("actions", "run"), Nil) private val ApiSettingsDecoder: Decode[ServerApiSettings] = - WireDecode.of(Json.decoder[ServerApiSettingsDto])(_.toDomain) + WireDecode.single(Json.decoder[ServerApiSettingsDto])(_.toDomain) private val RepositorySettingsDecoder: Decode[ServerRepositorySettings] = - WireDecode.of(Json.decoder[ServerRepositorySettingsDto])(_.toDomain) + WireDecode.single(Json.decoder[ServerRepositorySettingsDto])(_.toDomain) private val AttachmentSettingsDecoder: Decode[ServerAttachmentSettings] = - WireDecode.of(Json.decoder[ServerAttachmentSettingsDto])(_.toDomain) + WireDecode.single(Json.decoder[ServerAttachmentSettingsDto])(_.toDomain) private val SigningKeyDecoder: Decode[Option[SigningKey]] = PlainText.decodedAs(SigningKey.from) @@ -581,7 +581,7 @@ object MiscellaneousApi: PlainText.decodedAs(RenderedMarkdown.apply) private val UiSettingsDecoder: Decode[ServerUiSettings] = - WireDecode.of(Json.decoder[ServerUiSettingsDto])(_.toDomain) + WireDecode.single(Json.decoder[ServerUiSettingsDto])(_.toDomain) private val SshSigningKeyDecoder: Decode[Option[SshSigningKey]] = PlainText.decodedAs(SshSigningKey.from) @@ -593,7 +593,7 @@ object MiscellaneousApi: WireDecode.vector(Json.decoder[Vector[String]])(TemplateNamesDto.toDomainAll) private val GitignoreTemplateDecoder: Decode[GitignoreTemplate] = - WireDecode.of(Json.decoder[GitignoreTemplateDto])(_.toDomain) + WireDecode.single(Json.decoder[GitignoreTemplateDto])(_.toDomain) private val TemplateLabelsDecoder: Decode[Vector[TemplateLabel]] = WireDecode.vector(Json.decoder[Vector[TemplateLabelDto]])(TemplateLabelDto.toDomainAll) @@ -601,16 +601,16 @@ object MiscellaneousApi: private val LicenseTemplatesDecoder: Decode[Vector[LicenseTemplateSummary]] = WireDecode.vector(Json.decoder[Vector[LicenseTemplateSummaryDto]])(LicenseTemplateSummaryDto.toDomainAll) private val LicenseTemplateDecoder: Decode[LicenseTemplate] = - WireDecode.of(Json.decoder[LicenseTemplateDto])(_.toDomain) + WireDecode.single(Json.decoder[LicenseTemplateDto])(_.toDomain) private val NodeInfoDecoder: Decode[NodeInfo] = - WireDecode.of(Json.decoder[NodeInfoDto])(_.toDomain) + WireDecode.single(Json.decoder[NodeInfoDto])(_.toDomain) /** The run model the repository Actions group owns, reused verbatim: `GET /actions/run` answers the same `ActionRun` * object, so it is read by the same DTO rather than by a second copy of it. */ private val ActionsRunDecoder: Decode[ActionRun] = - WireDecode.of(Json.decoder[ActionRunDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionRunDto])(_.toDomain) private def settingsRequest(operation: String, area: String): CodebergRequest = read(operation, List("settings", area), Nil) diff --git a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala index 738ad75..a8d5837 100644 --- a/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/notifications/NotificationApi.scala @@ -315,10 +315,10 @@ object NotificationApi: List("repos", owner.value, name.value, "notifications") private val ThreadDecoder: Decode[NotificationThread] = - WireDecode.of(Json.decoder[NotificationThreadDto])(_.toDomain) + WireDecode.single(Json.decoder[NotificationThreadDto])(_.toDomain) private val ThreadsDecoder: Decode[Vector[NotificationThread]] = WireDecode.vector(Json.decoder[Vector[NotificationThreadDto]])(NotificationThreadDto.toDomainAll) private val CountDecoder: Decode[UnreadCount] = - WireDecode.of(Json.decoder[NotificationCountDto])(_.toDomain) + WireDecode.single(Json.decoder[NotificationCountDto])(_.toDomain) diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala index bee9b77..1def68e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala @@ -56,7 +56,7 @@ private[organizations] object OrganizationDecoders: /** One organisation object, as `GET /orgs/{org}` returns it. */ val organization: Decode[Organization] = - WireDecode.of(Json.decoder[OrganizationDto])(_.toDomain) + WireDecode.single(Json.decoder[OrganizationDto])(_.toDomain) /** A bare array of organisation objects, as `GET /orgs` and `GET /users/{username}/orgs` return it. */ val organizations: Decode[Vector[Organization]] = @@ -64,7 +64,7 @@ private[organizations] object OrganizationDecoders: /** One team object, as `GET /teams/{id}` returns it. */ val team: Decode[Team] = - WireDecode.of(Json.decoder[TeamDto])(_.toDomain) + WireDecode.single(Json.decoder[TeamDto])(_.toDomain) /** A bare array of team objects, as `GET /orgs/{org}/teams` returns it. */ val teams: Decode[Vector[Team]] = @@ -82,20 +82,20 @@ private[organizations] object OrganizationDecoders: /** One user object, as `GET /teams/{id}/members/{username}` returns it. */ val user: Decode[User] = - WireDecode.of(Json.decoder[UserDto])(_.toDomain) + WireDecode.single(Json.decoder[UserDto])(_.toDomain) /** One repository object, as `GET /teams/{id}/repos/{org}/{repo}` returns it. */ val repository: Decode[Repository] = - WireDecode.of(Json.decoder[RepositoryDto])(_.toDomain) + WireDecode.single(Json.decoder[RepositoryDto])(_.toDomain) /** The `{"ok", "data"}` envelope the team search returns; see the object note. */ val teamSearchResults: Decode[Vector[Team]] = - WireDecode.of(Json.decoder[SearchEnvelopeDto[TeamDto]]): envelope => + WireDecode.single(Json.decoder[SearchEnvelopeDto[TeamDto]]): envelope => Elements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) /** One webhook object, as the organisation hook routes return it. */ val webhook: Decode[Webhook] = - WireDecode.of(Json.decoder[WebhookDto])(_.toDomain) + WireDecode.single(Json.decoder[WebhookDto])(_.toDomain) /** A bare array of webhook objects, as `GET /orgs/{org}/hooks` returns it. */ val webhooks: Decode[Vector[Webhook]] = @@ -103,7 +103,7 @@ private[organizations] object OrganizationDecoders: /** One label object, as the organisation label routes return it. */ val label: Decode[Label] = - WireDecode.of(Json.decoder[LabelDto])(_.toDomain) + WireDecode.single(Json.decoder[LabelDto])(_.toDomain) /** A bare array of label objects; `golden/organization/org-labels-list.json` is a capture of exactly this. */ val labels: Decode[Vector[Label]] = @@ -119,11 +119,11 @@ private[organizations] object OrganizationDecoders: /** The five effective permission flags, as `GET /users/{username}/orgs/{org}/permissions` returns them. */ val permissions: Decode[OrganizationPermissions] = - WireDecode.of(Json.decoder[OrganizationPermissionsDto])(_.toDomain) + WireDecode.single(Json.decoder[OrganizationPermissionsDto])(_.toDomain) /** The quota tree, as `GET /orgs/{org}/quota` returns it. */ val quotaInfo: Decode[QuotaInfo] = - WireDecode.of(Json.decoder[QuotaInfoDto])(_.toDomain) + WireDecode.single(Json.decoder[QuotaInfoDto])(_.toDomain) /** The bare JSON boolean `GET /orgs/{org}/quota/check` answers with. * diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala index d0c9913..5c06619 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala @@ -43,7 +43,7 @@ private[actions] object OrganizationActionDecoders: /** One runner object. */ val runner: Decode[ActionRunner] = - WireDecode.of(Json.decoder[ActionRunnerDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionRunnerDto])(_.toDomain) /** A bare array of runner objects, as the organisation's runner listing returns it. */ val runners: Decode[Vector[ActionRunner]] = @@ -56,13 +56,13 @@ private[actions] object OrganizationActionDecoders: * [[com.worxbend.codeberg4s.CodebergError.DecodingFailed]]. */ val registeredRunner: Decode[RegisteredRunner] = - Decode.sensitive(WireDecode.of(Json.decoder[RegisteredRunnerDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegisteredRunnerDto])(_.toDomain)) /** The one-key object the registration-token endpoint returns — the same credential with nothing around it, and * [[com.worxbend.codeberg4s.core.Decode.sensitive]] for the same reason as [[registeredRunner]]. */ val registrationToken: Decode[RunnerRegistrationToken] = - Decode.sensitive(WireDecode.of(Json.decoder[RegistrationTokenDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegistrationTokenDto])(_.toDomain)) /** A bare array of job objects, as the runner job search returns it. */ val jobs: Decode[Vector[ActionRunJob]] = @@ -74,7 +74,7 @@ private[actions] object OrganizationActionDecoders: /** One variable object. */ val variable: Decode[ActionVariable] = - WireDecode.of(Json.decoder[ActionVariableDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionVariableDto])(_.toDomain) /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index 966a937..f484637 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -1413,19 +1413,19 @@ object PullRequestApi: reviewCommentsPath(owner, name, number, review) :+ comment.value.toString private val PullDecoder: Decode[PullRequest] = - WireDecode.of(Json.decoder[PullRequestDto])(_.toDomain) + WireDecode.single(Json.decoder[PullRequestDto])(_.toDomain) private val PullsDecoder: Decode[Vector[PullRequest]] = WireDecode.vector(Json.decoder[Vector[PullRequestDto]])(PullRequestDto.toDomainAll) private val ReviewDecoder: Decode[Review] = - WireDecode.of(Json.decoder[ReviewDto])(_.toDomain) + WireDecode.single(Json.decoder[ReviewDto])(_.toDomain) private val ReviewsDecoder: Decode[Vector[Review]] = WireDecode.vector(Json.decoder[Vector[ReviewDto]])(ReviewDto.toDomainAll) private val ReviewCommentDecoder: Decode[ReviewComment] = - WireDecode.of(Json.decoder[ReviewCommentDto])(_.toDomain) + WireDecode.single(Json.decoder[ReviewCommentDto])(_.toDomain) private val ReviewCommentsDecoder: Decode[Vector[ReviewComment]] = WireDecode.vector(Json.decoder[Vector[ReviewCommentDto]])(ReviewCommentDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala index f2cc730..5c4634a 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala @@ -29,7 +29,7 @@ private[repositories] object RepositoryDecoders: /** A repository object, as `GET /repos/{owner}/{repo}` returns it. */ val repository: Decode[Repository] = - WireDecode.of(Json.decoder[RepositoryDto])(_.toDomain) + WireDecode.single(Json.decoder[RepositoryDto])(_.toDomain) /** A bare array of repository objects, as the fork listing returns it. */ val repositories: Decode[Vector[Repository]] = @@ -37,12 +37,12 @@ private[repositories] object RepositoryDecoders: /** The `{"ok", "data"}` envelope the search endpoint returns. */ val searchResults: Decode[Vector[Repository]] = - WireDecode.of(Json.decoder[SearchEnvelopeDto[RepositoryDto]]): envelope => + WireDecode.single(Json.decoder[SearchEnvelopeDto[RepositoryDto]]): envelope => Elements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) /** One branch object. */ val branch: Decode[Branch] = - WireDecode.of(Json.decoder[BranchDto])(_.toDomain) + WireDecode.single(Json.decoder[BranchDto])(_.toDomain) /** A bare array of branch objects. */ val branches: Decode[Vector[Branch]] = @@ -58,7 +58,7 @@ private[repositories] object RepositoryDecoders: /** One release object. */ val release: Decode[Release] = - WireDecode.of(Json.decoder[ReleaseDto])(_.toDomain) + WireDecode.single(Json.decoder[ReleaseDto])(_.toDomain) /** A bare array of release objects. */ val releases: Decode[Vector[Release]] = @@ -66,14 +66,14 @@ private[repositories] object RepositoryDecoders: /** The `{"topics"}` envelope, unwrapped to the names it carries. */ val topics: Decode[Vector[String]] = - WireDecode.of(Json.decoder[TopicNamesDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[TopicNamesDto])(dto => Right(dto.toDomain)) /** Either arm of the contents union — see [[com.worxbend.codeberg4s.repositories.RepositoryContent]]. */ val contents: Decode[RepositoryContent] = - WireDecode.of(Json.decoder[RepositoryContentDto])(_.toDomain) + WireDecode.single(Json.decoder[RepositoryContentDto])(_.toDomain) /** A response body that is an array of DTOs, converted element by element with each failure at its own index. */ private def listOf[D, A](wire: Decode[Vector[D]])( one: (D, JsonPath) => Either[DecodeFailure, A] ): Decode[Vector[A]] = - WireDecode.of(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) + WireDecode.single(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala index 6b9b046..92a59cb 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala @@ -37,7 +37,7 @@ private[access] object RepositoryAccessDecoders: /** One branch protection rule. */ val branchProtection: Decode[BranchProtection] = - WireDecode.of(Json.decoder[BranchProtectionDto])(_.toDomain) + WireDecode.single(Json.decoder[BranchProtectionDto])(_.toDomain) /** A bare array of branch protection rules, as the unpaged listing returns it. */ val branchProtections: Decode[Vector[BranchProtection]] = @@ -45,7 +45,7 @@ private[access] object RepositoryAccessDecoders: /** One tag protection rule. */ val tagProtection: Decode[TagProtection] = - WireDecode.of(Json.decoder[TagProtectionDto])(_.toDomain) + WireDecode.single(Json.decoder[TagProtectionDto])(_.toDomain) /** A bare array of tag protection rules. */ val tagProtections: Decode[Vector[TagProtection]] = @@ -64,11 +64,11 @@ private[access] object RepositoryAccessDecoders: /** The `{permission, role_name, user}` object the collaborator permission endpoint answers. */ val collaboratorAccess: Decode[CollaboratorAccess] = - WireDecode.of(Json.decoder[CollaboratorAccessDto])(_.toDomain) + WireDecode.single(Json.decoder[CollaboratorAccessDto])(_.toDomain) /** One deploy key. */ val deployKey: Decode[DeployKey] = - WireDecode.of(Json.decoder[DeployKeyDto])(_.toDomain) + WireDecode.single(Json.decoder[DeployKeyDto])(_.toDomain) /** A bare array of deploy keys. */ val deployKeys: Decode[Vector[DeployKey]] = @@ -76,7 +76,7 @@ private[access] object RepositoryAccessDecoders: /** One team, which is what the team check answers rather than the `204` its siblings answer. */ val team: Decode[Team] = - WireDecode.of(Json.decoder[TeamDto])(_.toDomain) + WireDecode.single(Json.decoder[TeamDto])(_.toDomain) /** A bare array of teams, as `TeamListWithoutPagination` returns it. */ val teams: Decode[Vector[Team]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala index 2a3699a..e9fb86c 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala @@ -41,7 +41,7 @@ private[actions] object RepositoryActionDecoders: /** One artifact object. */ val artifact: Decode[ActionArtifact] = - WireDecode.of(Json.decoder[ActionArtifactDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionArtifactDto])(_.toDomain) /** A bare array of artifact objects, as both artifact listings return it. */ val artifacts: Decode[Vector[ActionArtifact]] = @@ -49,11 +49,11 @@ private[actions] object RepositoryActionDecoders: /** One run object. */ val run: Decode[ActionRun] = - WireDecode.of(Json.decoder[ActionRunDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionRunDto])(_.toDomain) /** The `{"total_count", "workflow_runs"}` envelope the run listing returns, unwrapped to its runs. */ val runs: Decode[Vector[ActionRun]] = - WireDecode.of(Json.decoder[WorkflowRunsEnvelopeDto[ActionRunDto]]): envelope => + WireDecode.single(Json.decoder[WorkflowRunsEnvelopeDto[ActionRunDto]]): envelope => ActionRunDto.toDomainAll(RepositoryActionDecoders.EntriesPath, envelope.entries) /** A bare array of job objects, as both the run's job listing and the runner job search return it. */ @@ -62,12 +62,12 @@ private[actions] object RepositoryActionDecoders: /** The same envelope as [[runs]], carrying tasks. The key is `workflow_runs` there too; see the envelope's note. */ val tasks: Decode[Vector[ActionTask]] = - WireDecode.of(Json.decoder[WorkflowRunsEnvelopeDto[ActionTaskDto]]): envelope => + WireDecode.single(Json.decoder[WorkflowRunsEnvelopeDto[ActionTaskDto]]): envelope => ActionTaskDto.toDomainAll(RepositoryActionDecoders.EntriesPath, envelope.entries) /** One runner object. */ val runner: Decode[ActionRunner] = - WireDecode.of(Json.decoder[ActionRunnerDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionRunnerDto])(_.toDomain) /** A bare array of runner objects. */ val runners: Decode[Vector[ActionRunner]] = @@ -80,13 +80,13 @@ private[actions] object RepositoryActionDecoders: * [[com.worxbend.codeberg4s.CodebergError.DecodingFailed]]. */ val registeredRunner: Decode[RegisteredRunner] = - Decode.sensitive(WireDecode.of(Json.decoder[RegisteredRunnerDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegisteredRunnerDto])(_.toDomain)) /** The one-key object the registration-token endpoint returns — the same credential with nothing around it, and * [[com.worxbend.codeberg4s.core.Decode.sensitive]] for the same reason as [[registeredRunner]]. */ val registrationToken: Decode[RunnerRegistrationToken] = - Decode.sensitive(WireDecode.of(Json.decoder[RegistrationTokenDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegistrationTokenDto])(_.toDomain)) /** A bare array of secret objects — names and timestamps, never values. */ val secrets: Decode[Vector[ActionSecret]] = @@ -94,7 +94,7 @@ private[actions] object RepositoryActionDecoders: /** One variable object. */ val variable: Decode[ActionVariable] = - WireDecode.of(Json.decoder[ActionVariableDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionVariableDto])(_.toDomain) /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = @@ -109,7 +109,7 @@ private[actions] object RepositoryActionDecoders: * is still understood and one that answers with a malformed body still fails. */ val dispatchedRun: Decode[Option[DispatchedWorkflowRun]] = - val present = WireDecode.of(Json.decoder[DispatchedWorkflowRunDto])(_.toDomain) + val present = WireDecode.single(Json.decoder[DispatchedWorkflowRunDto])(_.toDomain) (body: ResponseBody) => if body.isBlank then Right(None) else present(body).map(Some.apply) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala index 147b0cf..3653192 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala @@ -60,11 +60,11 @@ private[admin] object RepositoryAdminDecoders: /** A repository object, as create, edit, migrate, transfer and convert all answer. */ val repository: Decode[Repository] = - WireDecode.of(Json.decoder[RepositoryDto])(_.toDomain) + WireDecode.single(Json.decoder[RepositoryDto])(_.toDomain) /** One branch object, as the branch create answers. */ val branch: Decode[Branch] = - WireDecode.of(Json.decoder[BranchDto])(_.toDomain) + WireDecode.single(Json.decoder[BranchDto])(_.toDomain) /** A bare array of user objects, as the assignee, reviewer, stargazer and subscriber listings all return. */ val users: Decode[Vector[User]] = @@ -88,7 +88,7 @@ private[admin] object RepositoryAdminDecoders: /** One push-mirror object. */ val pushMirror: Decode[PushMirror] = - WireDecode.of(Json.decoder[PushMirrorDto])(_.toDomain) + WireDecode.single(Json.decoder[PushMirrorDto])(_.toDomain) /** A bare array of push-mirror objects. */ val pushMirrors: Decode[Vector[PushMirror]] = @@ -96,32 +96,32 @@ private[admin] object RepositoryAdminDecoders: /** The subscription object, which only a watcher ever receives — a non-watcher gets a `404`. */ val watchStatus: Decode[WatchStatus] = - WireDecode.of(Json.decoder[WatchInfoDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[WatchInfoDto])(dto => Right(dto.toDomain)) /** The fork-sync description both `sync_fork` reads answer. */ val forkSyncInfo: Decode[ForkSyncInfo] = - WireDecode.of(Json.decoder[SyncForkInfoDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[SyncForkInfoDto])(dto => Right(dto.toDomain)) /** The two-flag object the pin-allowance read answers. */ val issuePinsAllowed: Decode[IssuePinsAllowed] = - WireDecode.of(Json.decoder[IssuePinsAllowedDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[IssuePinsAllowedDto])(dto => Right(dto.toDomain)) /** The bare `{"language": bytes}` object the language statistics answer; see its DTO for why it is special. */ val languages: Decode[LanguageBreakdown] = - WireDecode.of(Json.decoder[LanguageStatisticsDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[LanguageStatisticsDto])(dto => Right(dto.toDomain)) /** The `{"topics": [...]}` envelope the topic search returns, unwrapped to the topics it carries. */ val topics: Decode[Vector[TopicSummary]] = - WireDecode.of(Json.decoder[TopicSearchEnvelopeDto]): envelope => + WireDecode.single(Json.decoder[TopicSearchEnvelopeDto]): envelope => TopicSummaryDto.toDomainAll(RepositoryAdminDecoders.TopicEntriesPath, envelope.entries) /** The single-file write response, shared with `POST /repos/{owner}/{repo}/diffpatch`. */ val fileChange: Decode[FileChange] = - WireDecode.of(Json.decoder[FileResponseDto])(_.toDomain) + WireDecode.single(Json.decoder[FileResponseDto])(_.toDomain) /** The batch write response. */ val fileChangeSet: Decode[FileChangeSet] = - WireDecode.of(Json.decoder[FilesResponseDto])(_.toDomain) + WireDecode.single(Json.decoder[FilesResponseDto])(_.toDomain) /** The repository's signing key, exactly as the instance sent it. * @@ -136,4 +136,4 @@ private[admin] object RepositoryAdminDecoders: private def listOf[D, A](wire: Decode[Vector[D]])( one: (D, JsonPath) => Either[DecodeFailure, A] ): Decode[Vector[A]] = - WireDecode.of(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) + WireDecode.single(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala index f62567e..0e2e920 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala @@ -40,7 +40,7 @@ private[gitdata] object GitDataDecoders: /** One `GitBlob` object. */ val blob: Decode[GitBlob] = - WireDecode.of(Json.decoder[GitBlobDto])(_.toDomain) + WireDecode.single(Json.decoder[GitBlobDto])(_.toDomain) /** A bare array of `GitBlob` objects, as the multi-blob read returns it. */ val blobs: Decode[Vector[GitBlob]] = @@ -49,15 +49,15 @@ private[gitdata] object GitDataDecoders: /** The `{"sha", "tree", …}` envelope, unwrapped to the entries it carries. */ val treeEntries: Decode[Vector[GitTreeEntry]] = - WireDecode.of(Json.decoder[GitTreeDto])(_.toDomain) + WireDecode.single(Json.decoder[GitTreeDto])(_.toDomain) /** One `Commit` object, as the single-commit read returns it. */ val commit: Decode[Commit] = - WireDecode.of(Json.decoder[CommitDto])(_.toDomain) + WireDecode.single(Json.decoder[CommitDto])(_.toDomain) /** One `Note` object. */ val note: Decode[GitNote] = - WireDecode.of(Json.decoder[NoteDto])(_.toDomain) + WireDecode.single(Json.decoder[NoteDto])(_.toDomain) /** A bare array of `Reference` objects. */ val references: Decode[Vector[GitReference]] = @@ -66,11 +66,11 @@ private[gitdata] object GitDataDecoders: /** One `AnnotatedTag` object. */ val annotatedTag: Decode[AnnotatedTag] = - WireDecode.of(Json.decoder[AnnotatedTagDto])(_.toDomain) + WireDecode.single(Json.decoder[AnnotatedTagDto])(_.toDomain) /** One `CombinedStatus` object, kept whole — see this object's own note. */ val combinedStatus: Decode[CombinedCommitStatus] = - WireDecode.of(Json.decoder[CombinedStatusDto])(_.toDomain) + WireDecode.single(Json.decoder[CombinedStatusDto])(_.toDomain) /** A bare array of `CommitStatus` objects. */ val commitStatuses: Decode[Vector[CommitStatus]] = @@ -79,19 +79,19 @@ private[gitdata] object GitDataDecoders: /** One `PullRequest` object, reusing the pull-request wave's model rather than a reduced copy of it. */ val pullRequest: Decode[PullRequest] = - WireDecode.of(Json.decoder[PullRequestDto])(_.toDomain) + WireDecode.single(Json.decoder[PullRequestDto])(_.toDomain) /** One `Compare` object. */ val comparison: Decode[CommitComparison] = - WireDecode.of(Json.decoder[CompareDto])(_.toDomain) + WireDecode.single(Json.decoder[CompareDto])(_.toDomain) /** One `FileResponse` object, as the diffpatch write answers with. */ val fileChange: Decode[FileChange] = - WireDecode.of(Json.decoder[FileResponseDto])(_.toDomain) + WireDecode.single(Json.decoder[FileResponseDto])(_.toDomain) /** The EditorConfig definitions object, whose property names are not known in advance. */ val editorConfig: Decode[EditorConfigDefinitions] = - WireDecode.of(Json.decoder[EditorConfigDto])(dto => Right(dto.toDomain)) + WireDecode.single(Json.decoder[EditorConfigDto])(dto => Right(dto.toDomain)) /** A body that is not JSON: a diff, a patch, or a file this transport could only decode as text. * diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala index e231625..167b8c1 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryHookDecoders.scala @@ -36,7 +36,7 @@ private[hooks] object RepositoryHookDecoders: /** One webhook object. */ val webhook: Decode[Webhook] = - WireDecode.of(Json.decoder[WebhookDto])(_.toDomain) + WireDecode.single(Json.decoder[WebhookDto])(_.toDomain) /** A bare array of webhook objects, as the hook listing returns it. */ val webhooks: Decode[Vector[Webhook]] = @@ -44,7 +44,7 @@ private[hooks] object RepositoryHookDecoders: /** One Git hook object. */ val gitHook: Decode[GitHook] = - WireDecode.of(Json.decoder[GitHookDto])(_.toDomain) + WireDecode.single(Json.decoder[GitHookDto])(_.toDomain) /** A bare array of Git hook objects. */ val gitHooks: Decode[Vector[GitHook]] = @@ -56,7 +56,7 @@ private[hooks] object RepositoryHookDecoders: /** One wiki page, content included. */ val wikiPage: Decode[WikiPage] = - WireDecode.of(Json.decoder[WikiPageDto])(_.toDomain) + WireDecode.single(Json.decoder[WikiPageDto])(_.toDomain) /** A bare array of wiki page listing entries — metadata only, no content. */ val wikiPages: Decode[Vector[WikiPageMeta]] = @@ -64,16 +64,16 @@ private[hooks] object RepositoryHookDecoders: /** The `{"commits", "count"}` envelope the revision listing returns, unwrapped to its revisions. */ val wikiRevisions: Decode[Vector[WikiCommit]] = - WireDecode.of(Json.decoder[WikiCommitListDto]): envelope => + WireDecode.single(Json.decoder[WikiCommitListDto]): envelope => WikiCommitDto.toDomainAll(RepositoryHookDecoders.RevisionsPath, envelope.entries) /** The repository's issue configuration. */ val issueConfig: Decode[IssueConfig] = - WireDecode.of(Json.decoder[IssueConfigDto])(_.toDomain) + WireDecode.single(Json.decoder[IssueConfigDto])(_.toDomain) /** The verdict on the repository's issue configuration. */ val issueConfigValidation: Decode[IssueConfigValidation] = - WireDecode.of(Json.decoder[IssueConfigValidationDto])(_.toDomain) + WireDecode.single(Json.decoder[IssueConfigValidationDto])(_.toDomain) /** A bare array of issue template objects. */ val issueTemplates: Decode[Vector[IssueTemplate]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala index 813453f..62310a3 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala @@ -31,7 +31,7 @@ private[publishing] object PublishingDecoders: * the elements of that capture are exactly what these endpoints send one of. */ val tag: Decode[Tag] = - WireDecode.of(Json.decoder[TagDto])(_.toDomain) + WireDecode.single(Json.decoder[TagDto])(_.toDomain) /** One attachment object, as the three single-asset endpoints return it. * @@ -39,7 +39,7 @@ private[publishing] object PublishingDecoders: * and its failures had to be reported at `$.assets[n]`. Here it '''is''' the body, so the path is the root. */ val asset: Decode[ReleaseAsset] = - WireDecode.of(Json.decoder[ReleaseAssetDto])(_.toDomainAt(JsonPath.Root)) + WireDecode.single(Json.decoder[ReleaseAssetDto])(_.toDomainAt(JsonPath.Root)) /** A bare array of attachment objects, as `GET /releases/{id}/assets` returns it. * diff --git a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala index ebfb821..d4b8251 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/UserApi.scala @@ -321,13 +321,13 @@ object UserApi: PagingQuery.window(params) private val UserDecoder: Decode[User] = - WireDecode.of(Json.decoder[UserDto])(_.toDomain) + WireDecode.single(Json.decoder[UserDto])(_.toDomain) private val UserListDecoder: Decode[Vector[User]] = WireDecode.vector(Json.decoder[Vector[UserDto]])(each(_, _)(_.toDomainAt(_))) private val UserSearchDecoder: Decode[Vector[User]] = - WireDecode.of(Json.decoder[SearchEnvelopeDto[UserDto]]): envelope => + WireDecode.single(Json.decoder[SearchEnvelopeDto[UserDto]]): envelope => each(JsonPath.Root.field("data"), envelope.data)(_.toDomainAt(_)) private val RepositoryListDecoder: Decode[Vector[Repository]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala index c4afc31..a312e68 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala @@ -54,7 +54,7 @@ private[account] object UserAccountDecoders: * of them. */ val application: Decode[OAuth2Application] = - WireDecode.of(Json.decoder[OAuth2ApplicationDto])(_.toDomain) + WireDecode.single(Json.decoder[OAuth2ApplicationDto])(_.toDomain) /** The same object as [[application]], from the responses that '''do''' carry `client_secret`. * @@ -77,11 +77,11 @@ private[account] object UserAccountDecoders: /** The account's settings object, returned by both the read and the update. */ val settings: Decode[UserSettings] = - WireDecode.of(Json.decoder[UserSettingsDto])(_.toDomain) + WireDecode.single(Json.decoder[UserSettingsDto])(_.toDomain) /** The account's quota report, with its nested `used` tree already flattened. */ val quota: Decode[QuotaInfo] = - WireDecode.of(Json.decoder[QuotaInfoDto])(_.toDomain) + WireDecode.single(Json.decoder[QuotaInfoDto])(_.toDomain) /** A bare array of quota-counting artifacts. */ val quotaArtifacts: Decode[Vector[QuotaUsedArtifact]] = @@ -107,7 +107,7 @@ private[account] object UserAccountDecoders: /** One repository object, as the creation returns it. */ val repository: Decode[Repository] = - WireDecode.of(Json.decoder[RepositoryDto])(_.toDomain) + WireDecode.single(Json.decoder[RepositoryDto])(_.toDomain) /** A bare array of repository objects, as the account's repository listing returns it. */ val repositories: Decode[Vector[Repository]] = @@ -120,7 +120,7 @@ private[account] object UserAccountDecoders: /** One runner object. */ val runner: Decode[ActionRunner] = - WireDecode.of(Json.decoder[ActionRunnerDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionRunnerDto])(_.toDomain) /** A bare array of runner objects. */ val runners: Decode[Vector[ActionRunner]] = @@ -137,17 +137,17 @@ private[account] object UserAccountDecoders: * [[com.worxbend.codeberg4s.CodebergError.DecodingFailed]]. */ val registeredRunner: Decode[RegisteredRunner] = - Decode.sensitive(WireDecode.of(Json.decoder[RegisteredRunnerDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegisteredRunnerDto])(_.toDomain)) /** The one-key object the registration-token endpoint returns — the same credential with nothing around it, and * [[com.worxbend.codeberg4s.core.Decode.sensitive]] for the same reason as [[registeredRunner]]. */ val registrationToken: Decode[RunnerRegistrationToken] = - Decode.sensitive(WireDecode.of(Json.decoder[RegistrationTokenDto])(_.toDomain)) + Decode.sensitive(WireDecode.single(Json.decoder[RegistrationTokenDto])(_.toDomain)) /** One variable object. */ val variable: Decode[ActionVariable] = - WireDecode.of(Json.decoder[ActionVariableDto])(_.toDomain) + WireDecode.single(Json.decoder[ActionVariableDto])(_.toDomain) /** A bare array of variable objects. */ val variables: Decode[Vector[ActionVariable]] = @@ -155,7 +155,7 @@ private[account] object UserAccountDecoders: /** One webhook object. */ val webhook: Decode[Webhook] = - WireDecode.of(Json.decoder[WebhookDto])(_.toDomain) + WireDecode.single(Json.decoder[WebhookDto])(_.toDomain) /** A bare array of webhook objects, as the account's hook listing returns it. */ val webhooks: Decode[Vector[Webhook]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala index 592d995..d5f4230 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala @@ -90,7 +90,7 @@ private[social] object SocialDecoders: /** One GPG key object. */ val gpgKey: Decode[GpgKey] = - WireDecode.of(Json.decoder[GpgKeyDto])(_.toDomain) + WireDecode.single(Json.decoder[GpgKeyDto])(_.toDomain) /** A bare array of GPG key objects. */ val gpgKeys: Decode[Vector[GpgKey]] = @@ -98,7 +98,7 @@ private[social] object SocialDecoders: /** One SSH public key object, as `POST /user/keys` and `GET /user/keys/{id}` return it. */ val publicKey: Decode[PublicKey] = - WireDecode.of(Json.decoder[PublicKeyDto])(_.toDomain) + WireDecode.single(Json.decoder[PublicKeyDto])(_.toDomain) /** A bare array of access-token objects, with the credential field dropped unconditionally. */ val accessTokens: Decode[Vector[AccessToken]] = @@ -112,7 +112,7 @@ private[social] object SocialDecoders: * [[com.worxbend.codeberg4s.core.ApiPipeline.redactedSnippet]] for what the excerpt becomes instead. */ val createdAccessToken: Decode[CreatedAccessToken] = - Decode.sensitive(WireDecode.of(Json.decoder[AccessTokenDto])(_.toCreated)) + Decode.sensitive(WireDecode.single(Json.decoder[AccessTokenDto])(_.toCreated)) /** The plain-text challenge `GET /user/gpg_key_token` answers. * From 6942154313ade7592220ab0c61a1894f4f2ada82 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:25:57 +0300 Subject: [PATCH 21/31] refactor(codec): fold the per-group array helpers into ArrayElements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoint groups each carried a helper that did the same thing: `repositories.wire.Elements.convert` and `issues.wire.WireElements.at` both turned an element's position into a JsonPath segment and then delegated the walk itself to `codec.ArrayElements.convert`. Their own scaladoc named the duplication and said each was a candidate to move into `codec` once a second group needed it — which is exactly where the library ended up, with the pulls, users, organizations and repositories DTOs importing one or the other more or less at random. The path-adding step is now an overload of `ArrayElements.convert` taking the array's own path, so there is one helper instead of three layered objects, and no call site has to know which group first happened to need it. Behaviour is unchanged: a single bad element still fails the whole array, and a failure still reports `$[7].sha` rather than `$`. --- .../codeberg4s/issues/IssueDecoders.scala | 4 +- .../organizations/OrganizationDecoders.scala | 8 ++-- .../codeberg4s/pulls/PullRequestApi.scala | 4 +- .../repositories/RepositoryDecoders.scala | 6 +-- .../access/RepositoryAccessDecoders.scala | 4 +- .../admin/RepositoryAdminDecoders.scala | 4 +- .../gitdata/GitDataDecoders.scala | 8 ++-- .../publishing/PublishingDecoders.scala | 4 +- .../users/account/UserAccountDecoders.scala | 4 +- .../users/social/SocialDecoders.scala | 6 +-- .../codeberg4s/codec/ArrayElements.scala | 31 ++++++++++++--- .../issues/wire/AttachmentDto.scala | 3 +- .../codeberg4s/issues/wire/CommentDto.scala | 3 +- .../codeberg4s/issues/wire/IssueDto.scala | 5 ++- .../codeberg4s/issues/wire/LabelDto.scala | 3 +- .../codeberg4s/issues/wire/MilestoneDto.scala | 3 +- .../codeberg4s/issues/wire/ReactionDto.scala | 3 +- .../issues/wire/TimelineCommentDto.scala | 3 +- .../issues/wire/TrackedTimeDto.scala | 3 +- .../codeberg4s/issues/wire/WireElements.scala | 37 ------------------ .../wire/LicenseTemplateSummaryDto.scala | 4 +- .../miscellaneous/wire/TemplateLabelDto.scala | 4 +- .../miscellaneous/wire/TemplateNamesDto.scala | 6 +-- .../wire/NotificationThreadDto.scala | 8 ++-- .../organizations/wire/BlockedUserDto.scala | 4 +- .../organizations/wire/OrganizationDto.scala | 4 +- .../organizations/wire/QuotaUsedDto.scala | 8 ++-- .../organizations/wire/TeamDto.scala | 4 +- .../pulls/wire/ChangedFileDto.scala | 4 +- .../pulls/wire/PullRequestDto.scala | 6 +-- .../pulls/wire/ReviewCommentDto.scala | 4 +- .../codeberg4s/pulls/wire/ReviewDto.scala | 4 +- .../access/wire/BranchProtectionDto.scala | 4 +- .../access/wire/DeployKeyDto.scala | 4 +- .../access/wire/TagProtectionDto.scala | 4 +- .../actions/wire/ActionArtifactDto.scala | 4 +- .../actions/wire/ActionRunDto.scala | 4 +- .../actions/wire/ActionRunJobDto.scala | 4 +- .../actions/wire/ActionRunnerDto.scala | 4 +- .../actions/wire/ActionSecretDto.scala | 4 +- .../actions/wire/ActionTaskDto.scala | 4 +- .../actions/wire/ActionVariableDto.scala | 4 +- .../repositories/admin/wire/ActivityDto.scala | 4 +- .../admin/wire/FilesResponseDto.scala | 4 +- .../admin/wire/PushMirrorDto.scala | 4 +- .../admin/wire/TopicSummaryDto.scala | 4 +- .../gitdata/wire/CombinedStatusDto.scala | 4 +- .../gitdata/wire/CompareDto.scala | 6 +-- .../gitdata/wire/FileCommitDto.scala | 4 +- .../gitdata/wire/GitTreeDto.scala | 4 +- .../repositories/hooks/wire/GitHookDto.scala | 4 +- .../hooks/wire/IssueConfigDto.scala | 4 +- .../hooks/wire/IssueTemplateDto.scala | 4 +- .../hooks/wire/RepositoryFlagWire.scala | 4 +- .../repositories/hooks/wire/WebhookDto.scala | 4 +- .../repositories/hooks/wire/WikiPageDto.scala | 6 +-- .../repositories/wire/CommitDto.scala | 5 ++- .../repositories/wire/Elements.scala | 38 ------------------- .../repositories/wire/ReleaseDto.scala | 3 +- .../wire/RepositoryContentDto.scala | 3 +- .../users/account/wire/EmailDto.scala | 4 +- .../account/wire/OAuth2ApplicationDto.scala | 4 +- .../users/account/wire/QuotaUsageDto.scala | 12 +++--- .../users/social/wire/AccessTokenDto.scala | 4 +- .../users/social/wire/BlockedUserDto.scala | 4 +- .../users/social/wire/GpgKeyDto.scala | 10 ++--- .../users/social/wire/HeatmapEntryDto.scala | 4 +- .../users/social/wire/StopWatchDto.scala | 4 +- .../repositories/wire/BranchDtoSuite.scala | 3 +- .../repositories/wire/CommitDtoSuite.scala | 5 ++- .../repositories/wire/ReleaseDtoSuite.scala | 3 +- .../repositories/wire/TagDtoSuite.scala | 3 +- 72 files changed, 187 insertions(+), 228 deletions(-) delete mode 100644 modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireElements.scala delete mode 100644 modules/codec/src/com/worxbend/codeberg4s/repositories/wire/Elements.scala diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala index 1e515e3..8ac2820 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.ResponseBody @@ -14,7 +15,6 @@ import com.worxbend.codeberg4s.issues.wire.MilestoneDto import com.worxbend.codeberg4s.issues.wire.ReactionDto import com.worxbend.codeberg4s.issues.wire.TimelineCommentDto import com.worxbend.codeberg4s.issues.wire.TrackedTimeDto -import com.worxbend.codeberg4s.issues.wire.WireElements import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -114,7 +114,7 @@ private[issues] object IssueDecoders: */ val users: Decode[Vector[User]] = WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => - WireElements.at(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** One tracked-time entry, as adding time returns it. */ val trackedTime: Decode[TrackedTime] = diff --git a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala index 1def68e..0cebfd8 100644 --- a/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/organizations/OrganizationDecoders.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.organizations import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.issues.Label @@ -19,7 +20,6 @@ import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity import com.worxbend.codeberg4s.repositories.admin.wire.ActivityDto import com.worxbend.codeberg4s.repositories.hooks.Webhook import com.worxbend.codeberg4s.repositories.hooks.wire.WebhookDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -73,12 +73,12 @@ private[organizations] object OrganizationDecoders: /** A bare array of user objects, as the member and team-member listings return it. */ val users: Decode[Vector[User]] = WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of repository objects, as the organisation and team repository listings return it. */ val repositories: Decode[Vector[Repository]] = WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** One user object, as `GET /teams/{id}/members/{username}` returns it. */ val user: Decode[User] = @@ -91,7 +91,7 @@ private[organizations] object OrganizationDecoders: /** The `{"ok", "data"}` envelope the team search returns; see the object note. */ val teamSearchResults: Decode[Vector[Team]] = WireDecode.single(Json.decoder[SearchEnvelopeDto[TeamDto]]): envelope => - Elements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) + ArrayElements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) /** One webhook object, as the organisation hook routes return it. */ val webhook: Decode[Webhook] = diff --git a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala index f484637..66ef8de 100644 --- a/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/pulls/PullRequestApi.scala @@ -5,6 +5,7 @@ import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.ApiPipeline import com.worxbend.codeberg4s.core.CodebergRequest @@ -34,7 +35,6 @@ import com.worxbend.codeberg4s.pulls.wire.SubmitPullReviewOptionsDto import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.Commit import com.worxbend.codeberg4s.repositories.wire.CommitDto -import com.worxbend.codeberg4s.repositories.wire.Elements import scala.concurrent.Future @@ -1432,7 +1432,7 @@ object PullRequestApi: private val CommitsDecoder: Decode[Vector[Commit]] = WireDecode.vector(Json.decoder[Vector[CommitDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) private val FilesDecoder: Decode[Vector[ChangedFile]] = WireDecode.vector(Json.decoder[Vector[ChangedFileDto]])(ChangedFileDto.toDomainAll) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala index 5c4634a..37f0b7f 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/RepositoryDecoders.scala @@ -2,12 +2,12 @@ package com.worxbend.codeberg4s.repositories import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.wire.BranchDto import com.worxbend.codeberg4s.repositories.wire.CommitDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.ReleaseDto import com.worxbend.codeberg4s.repositories.wire.RepositoryContentDto import com.worxbend.codeberg4s.repositories.wire.RepositoryDto @@ -38,7 +38,7 @@ private[repositories] object RepositoryDecoders: /** The `{"ok", "data"}` envelope the search endpoint returns. */ val searchResults: Decode[Vector[Repository]] = WireDecode.single(Json.decoder[SearchEnvelopeDto[RepositoryDto]]): envelope => - Elements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) + ArrayElements.convert(JsonPath.Root.field("data"), envelope.data)((dto, at) => dto.toDomainAt(at)) /** One branch object. */ val branch: Decode[Branch] = @@ -76,4 +76,4 @@ private[repositories] object RepositoryDecoders: private def listOf[D, A](wire: Decode[Vector[D]])( one: (D, JsonPath) => Either[DecodeFailure, A] ): Decode[Vector[A]] = - WireDecode.single(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) + WireDecode.single(wire)(dtos => ArrayElements.convert(JsonPath.Root, dtos)(one)) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala index 92a59cb..4f8b83d 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessDecoders.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.access import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.organizations.Team @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.repositories.access.wire.BranchProtectionDto import com.worxbend.codeberg4s.repositories.access.wire.CollaboratorAccessDto import com.worxbend.codeberg4s.repositories.access.wire.DeployKeyDto import com.worxbend.codeberg4s.repositories.access.wire.TagProtectionDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -60,7 +60,7 @@ private[access] object RepositoryAccessDecoders: */ val collaborators: Decode[Vector[User]] = WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** The `{permission, role_name, user}` object the collaborator permission endpoint answers. */ val collaboratorAccess: Decode[CollaboratorAccess] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala index 3653192..1a06d8c 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminDecoders.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.repositories.admin import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.DecodeFailure @@ -27,7 +28,6 @@ import com.worxbend.codeberg4s.repositories.gitdata.FileChange import com.worxbend.codeberg4s.repositories.gitdata.wire.FileResponseDto import com.worxbend.codeberg4s.repositories.wire.BranchDto import com.worxbend.codeberg4s.repositories.wire.ContentEntryDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -136,4 +136,4 @@ private[admin] object RepositoryAdminDecoders: private def listOf[D, A](wire: Decode[Vector[D]])( one: (D, JsonPath) => Either[DecodeFailure, A] ): Decode[Vector[A]] = - WireDecode.single(wire)(dtos => Elements.convert(JsonPath.Root, dtos)(one)) + WireDecode.single(wire)(dtos => ArrayElements.convert(JsonPath.Root, dtos)(one)) diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala index 0e2e920..41e9c16 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/GitDataDecoders.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.gitdata import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.miscellaneous.PlainText @@ -18,7 +19,6 @@ import com.worxbend.codeberg4s.repositories.gitdata.wire.GitTreeDto import com.worxbend.codeberg4s.repositories.gitdata.wire.NoteDto import com.worxbend.codeberg4s.repositories.gitdata.wire.ReferenceDto import com.worxbend.codeberg4s.repositories.wire.CommitDto -import com.worxbend.codeberg4s.repositories.wire.Elements /** Every response shape [[RepositoryGitApi]] can receive, decoded once and shared. * @@ -45,7 +45,7 @@ private[gitdata] object GitDataDecoders: /** A bare array of `GitBlob` objects, as the multi-blob read returns it. */ val blobs: Decode[Vector[GitBlob]] = WireDecode.vector(Json.decoder[Vector[GitBlobDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** The `{"sha", "tree", …}` envelope, unwrapped to the entries it carries. */ val treeEntries: Decode[Vector[GitTreeEntry]] = @@ -62,7 +62,7 @@ private[gitdata] object GitDataDecoders: /** A bare array of `Reference` objects. */ val references: Decode[Vector[GitReference]] = WireDecode.vector(Json.decoder[Vector[ReferenceDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** One `AnnotatedTag` object. */ val annotatedTag: Decode[AnnotatedTag] = @@ -75,7 +75,7 @@ private[gitdata] object GitDataDecoders: /** A bare array of `CommitStatus` objects. */ val commitStatuses: Decode[Vector[CommitStatus]] = WireDecode.vector(Json.decoder[Vector[CommitStatusDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** One `PullRequest` object, reusing the pull-request wave's model rather than a reduced copy of it. */ val pullRequest: Decode[PullRequest] = diff --git a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala index 62310a3..562e447 100644 --- a/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/repositories/publishing/PublishingDecoders.scala @@ -2,11 +2,11 @@ package com.worxbend.codeberg4s.repositories.publishing import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.repositories.ReleaseAsset import com.worxbend.codeberg4s.repositories.Tag -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.ReleaseAssetDto import com.worxbend.codeberg4s.repositories.wire.TagDto @@ -48,4 +48,4 @@ private[publishing] object PublishingDecoders: */ val assets: Decode[Vector[ReleaseAsset]] = WireDecode.vector(Json.decoder[Vector[ReleaseAssetDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) diff --git a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala index a312e68..60e710d 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.users.account import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.organizations.Team @@ -18,7 +19,6 @@ import com.worxbend.codeberg4s.repositories.actions.wire.RegisteredRunnerDto import com.worxbend.codeberg4s.repositories.actions.wire.RegistrationTokenDto import com.worxbend.codeberg4s.repositories.hooks.Webhook import com.worxbend.codeberg4s.repositories.hooks.wire.WebhookDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto import com.worxbend.codeberg4s.users.account.wire.EmailDto import com.worxbend.codeberg4s.users.account.wire.OAuth2ApplicationDto @@ -112,7 +112,7 @@ private[account] object UserAccountDecoders: /** A bare array of repository objects, as the account's repository listing returns it. */ val repositories: Decode[Vector[Repository]] = WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of team objects, as the account's team listing returns it. */ val teams: Decode[Vector[Team]] = diff --git a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala index d5f4230..c55f16e 100644 --- a/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala +++ b/modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.users.social import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.client.WireDecode +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.core.Decode import com.worxbend.codeberg4s.core.DecodeFailure @@ -12,7 +13,6 @@ import com.worxbend.codeberg4s.miscellaneous.PlainText import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity import com.worxbend.codeberg4s.repositories.admin.wire.ActivityDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto import com.worxbend.codeberg4s.users.PublicKey import com.worxbend.codeberg4s.users.User @@ -50,12 +50,12 @@ private[social] object SocialDecoders: /** A bare array of user objects, as every follower and following listing returns it. */ val users: Decode[Vector[User]] = WireDecode.vector(Json.decoder[Vector[UserDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of repository objects, as the starred and watched listings return them. */ val repositories: Decode[Vector[Repository]] = WireDecode.vector(Json.decoder[Vector[RepositoryDto]]): (at, dtos) => - Elements.convert(at, dtos)(_.toDomainAt(_)) + ArrayElements.convert(at, dtos)(_.toDomainAt(_)) /** A bare array of block entries. */ val blockedUsers: Decode[Vector[BlockedUser]] = diff --git a/modules/codec/src/com/worxbend/codeberg4s/codec/ArrayElements.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/ArrayElements.scala index caf0efb..a5c97fd 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/codec/ArrayElements.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/ArrayElements.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.codec +import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.core.DecodeFailure import scala.annotation.tailrec @@ -7,16 +8,16 @@ import scala.annotation.tailrec /** Applies a conversion that can fail to every element of an array, stopping at the first element that fails. * * Four places in this module wanted exactly this and each had grown its own copy: [[JsonDecoder.arrayOf]] and - * [[JsonDecoder.all]] for `JSON → DTO`, and the two `wire` helpers - * ([[com.worxbend.codeberg4s.repositories.wire.Elements]] and [[com.worxbend.codeberg4s.issues.wire.WireElements]]) - * for `DTO → domain`. The copies agreed on the contract, which is the only reason they were survivable; they are here - * once so they cannot start disagreeing. + * [[JsonDecoder.all]] for `JSON → DTO`, and a `wire` helper per endpoint group for `DTO → domain`. The copies agreed + * on the contract, which is the only reason they were survivable; they are here once so they cannot start disagreeing. * * '''One bad element fails the whole array.''' A listing that silently dropped a malformed element would under-report, * and a caller cannot tell an under-report from a short page. * - * The conversion receives each element's zero-based position, because three of the four callers turn it into a - * [[com.worxbend.codeberg4s.JsonPath]] segment so a failure reads `$[7].sha` rather than `$`. + * Two shapes are offered, because callers arrive from two directions. The `JSON → DTO` decoders think in positions; + * every `DTO → domain` conversion instead has to name a [[com.worxbend.codeberg4s.JsonPath]], so that rule 5 of + * [[WireConventions]] can report `$[7].sha` rather than `$`. The path-shaped overload turns the position into that + * path segment, which is the whole of what the per-group helpers used to do. */ private[codeberg4s] object ArrayElements: @@ -46,3 +47,21 @@ private[codeberg4s] object ArrayElements: else Right(converted.result()) loop(0) + + /** Converts every element in order, giving each one its own path inside the response document. + * + * The same first-failure-wins contract as the overload above; the only addition is the index segment, so a failure + * inside the third element of a `labels` array reads `$.labels[2].name`. + * + * @param at + * the path of the '''array''' itself — [[com.worxbend.codeberg4s.JsonPath.Root]] for a response body that is an + * array, the field's path for an array nested in an object + * @param values + * the decoded elements, in the order the server sent them + * @param one + * converts a single element, given the element and the path that element sits at + */ + def convert[D, A](at: JsonPath, values: Vector[D])( + one: (D, JsonPath) => Either[DecodeFailure, A] + ): Either[DecodeFailure, Vector[A]] = + convert(values)((value, position) => one(value, at.index(position))) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AttachmentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AttachmentDto.scala index 6f51dbd..bb8f845 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AttachmentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AttachmentDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -86,4 +87,4 @@ object AttachmentDto: /** Converts a decoded array of attachments, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[AttachmentDto]): Either[DecodeFailure, Vector[IssueAttachment]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala index b55591e..e6aea70 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -95,4 +96,4 @@ object CommentDto: /** Converts a decoded array of comments, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[CommentDto]): Either[DecodeFailure, Vector[Comment]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala index c35c7db..b1ef417 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -124,7 +125,7 @@ final case class IssueDto( user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) private def assigneesAt(at: JsonPath): Either[DecodeFailure, Vector[User]] = - WireElements.at(at.field("assignees"), assignees)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(at.field("assignees"), assignees)((dto, path) => dto.toDomainAt(path)) private def milestoneAt(at: JsonPath): Either[DecodeFailure, Option[Milestone]] = milestone.fold(Right(None))(dto => dto.toDomainAt(at.field("milestone")).map(Some.apply)) @@ -173,4 +174,4 @@ object IssueDto: /** Converts a decoded array of issues, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[IssueDto]): Either[DecodeFailure, Vector[Issue]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/LabelDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/LabelDto.scala index 5a2996b..90f820f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/LabelDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/LabelDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -82,4 +83,4 @@ object LabelDto: /** Converts a decoded array of labels, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[LabelDto]): Either[DecodeFailure, Vector[Label]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneDto.scala index 5228201..a50d577 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -90,4 +91,4 @@ object MilestoneDto: /** Converts a decoded array of milestones, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[MilestoneDto]): Either[DecodeFailure, Vector[Milestone]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala index 2fb1036..e20da5b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -60,4 +61,4 @@ object ReactionDto: /** Converts a decoded array of reactions, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ReactionDto]): Either[DecodeFailure, Vector[Reaction]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala index b2a8162..d81a9ef 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -192,4 +193,4 @@ object TimelineCommentDto: /** Converts a decoded array of timeline entries, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[TimelineCommentDto]): Either[DecodeFailure, Vector[TimelineEvent]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala index ccaae3f..20b91d9 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -91,7 +92,7 @@ object TrackedTimeDto: /** Converts a decoded array of entries, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[TrackedTimeDto]): Either[DecodeFailure, Vector[TrackedTime]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** The wire's seconds as a duration. One line, in one place, so the unit is never re-derived. */ def asDuration(seconds: Long): FiniteDuration = diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireElements.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireElements.scala deleted file mode 100644 index 33e1fe6..0000000 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireElements.scala +++ /dev/null @@ -1,37 +0,0 @@ -package com.worxbend.codeberg4s.issues.wire - -import com.worxbend.codeberg4s.JsonPath -import com.worxbend.codeberg4s.codec.ArrayElements -import com.worxbend.codeberg4s.core.DecodeFailure - -/** Converts the elements of a decoded JSON array, reporting the position of whichever one failed. - * - * [[com.worxbend.codeberg4s.codec.Wire]] does this for a field; there is no equivalent for an element, and this group - * needs one five times over — four list endpoints plus the `labels` and `assignees` arrays nested inside every issue. - * Writing the path construction once means a decoding failure says `$[2].id` or `$.labels[1].name` rather than `$`, - * and means the five call sites cannot drift into disagreeing about whether one bad element fails the page. The walk - * over the elements is [[com.worxbend.codeberg4s.codec.ArrayElements]]'s. - * - * '''One bad element fails the whole conversion.''' That is the same contract [[com.worxbend.codeberg4s.codec.Json]] - * gives a list body, and the same one [[com.worxbend.codeberg4s.repositories.wire.RepositoryDto]] gives a nested - * model: a caller that silently received nineteen of twenty issues would have no way to notice. - * - * Internal to this group's wire package, and a candidate to move into `com.worxbend.codeberg4s.codec` once a second - * endpoint group decodes a list. - */ -private[codeberg4s] object WireElements: - - /** Converts every element of `dtos`, stopping at the first failure. - * - * @param base - * the path of the '''array''' inside the response document — [[com.worxbend.codeberg4s.JsonPath.Root]] for a body - * that is the array itself, `at.field("labels")` for an array nested in an object - * @param dtos - * the already-decoded elements, in the order the server returned them - * @param convert - * an element's own conversion, given the element's path - */ - def at[D, A](base: JsonPath, dtos: Vector[D])( - convert: (D, JsonPath) => Either[DecodeFailure, A] - ): Either[DecodeFailure, Vector[A]] = - ArrayElements.convert(dtos)((dto, position) => convert(dto, base.index(position))) diff --git a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/LicenseTemplateSummaryDto.scala b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/LicenseTemplateSummaryDto.scala index 19cd5da..031d274 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/LicenseTemplateSummaryDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/LicenseTemplateSummaryDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.miscellaneous.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.miscellaneous.LicenseTemplateSummary import com.worxbend.codeberg4s.miscellaneous.TemplateName -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `LicensesTemplateListEntry` model — one element of `GET /licenses`. * @@ -63,4 +63,4 @@ object LicenseTemplateSummaryDto: base: JsonPath, dtos: Vector[LicenseTemplateSummaryDto], ): Either[DecodeFailure, Vector[LicenseTemplateSummary]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateLabelDto.scala b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateLabelDto.scala index d9d99f8..6ae2d2c 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateLabelDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateLabelDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.miscellaneous.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.LabelColor import com.worxbend.codeberg4s.miscellaneous.TemplateLabel -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `LabelTemplate` model — one element of `GET /label/templates/{name}`. * @@ -79,4 +79,4 @@ object TemplateLabelDto: /** Converts a decoded array of template labels, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[TemplateLabelDto]): Either[DecodeFailure, Vector[TemplateLabel]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateNamesDto.scala b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateNamesDto.scala index b8fd0b5..3008977 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateNamesDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/miscellaneous/wire/TemplateNamesDto.scala @@ -1,9 +1,9 @@ package com.worxbend.codeberg4s.miscellaneous.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.miscellaneous.TemplateName -import com.worxbend.codeberg4s.repositories.wire.Elements /** The two listings whose body is a bare array of '''strings''' — `GET /gitignore/templates` and * `GET /label/templates`. @@ -22,7 +22,7 @@ object TemplateNamesDto: /** Converts a decoded array of names, reporting the position of whichever element failed. * * '''One bad name fails the whole listing''', which is the contract every array-shaped body in this library has — - * see [[com.worxbend.codeberg4s.repositories.wire.Elements]]. It is a very unlikely failure: + * see [[com.worxbend.codeberg4s.codec.ArrayElements]]. It is a very unlikely failure: * [[com.worxbend.codeberg4s.miscellaneous.TemplateName]] rejects only a blank name, a control character and a `.` or * `..` part, none of which is a file name a Forgejo distribution ships. * @@ -32,5 +32,5 @@ object TemplateNamesDto: * the decoded strings, in wire order */ def toDomainAll(base: JsonPath, names: Vector[String]): Either[DecodeFailure, Vector[TemplateName]] = - Elements.convert(base, names): (name, path) => + ArrayElements.convert(base, names): (name, path) => TemplateName.from(name).left.map(error => DecodeFailure(path, error.message)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala index 6a62d5d..296b1f8 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.notifications.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.notifications.NotificationSubject import com.worxbend.codeberg4s.notifications.NotificationThread import com.worxbend.codeberg4s.notifications.NotificationThreadId import com.worxbend.codeberg4s.repositories.Repository -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto /** Forgejo's `NotificationThread` model, field for field. @@ -33,8 +33,8 @@ import com.worxbend.codeberg4s.repositories.wire.RepositoryDto * * `repository` recurses into the repository group's own DTO rather than a reduced copy — `docs/LEDGER.md` gives * `Repository` to that group and calls a fork a review-blocking defect — and [[toDomainAll]] reuses that group's - * [[com.worxbend.codeberg4s.repositories.wire.Elements]] for the same reason. The dependency exists either way, - * because the embedded repository already brings it. + * [[com.worxbend.codeberg4s.codec.ArrayElements]] for the same reason. The dependency exists either way, because the + * embedded repository already brings it. */ final case class NotificationThreadDto( id: Option[Long], @@ -114,4 +114,4 @@ object NotificationThreadDto: base: JsonPath, dtos: Vector[NotificationThreadDto], ): Either[DecodeFailure, Vector[NotificationThread]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/BlockedUserDto.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/BlockedUserDto.scala index 7808338..f60d0fe 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/BlockedUserDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/BlockedUserDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.organizations.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.organizations.BlockId import com.worxbend.codeberg4s.organizations.BlockedUser -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `BlockedUser` model, field for field — both of its fields. * @@ -58,4 +58,4 @@ object BlockedUserDto: /** Converts a decoded array of entries, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[BlockedUserDto]): Either[DecodeFailure, Vector[BlockedUser]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationDto.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationDto.scala index d39b948..d8f44e6 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/OrganizationDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.organizations.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.organizations.OrgName import com.worxbend.codeberg4s.organizations.Organization -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.UserVisibility /** Forgejo's `Organization` model, field for field. @@ -106,4 +106,4 @@ object OrganizationDto: /** Converts a decoded array of organisations, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[OrganizationDto]): Either[DecodeFailure, Vector[Organization]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/QuotaUsedDto.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/QuotaUsedDto.scala index 0925b9c..194ef1c 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/QuotaUsedDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/QuotaUsedDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.organizations.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.core.DecodeFailure @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.organizations.QuotaArtifact import com.worxbend.codeberg4s.organizations.QuotaAttachment import com.worxbend.codeberg4s.organizations.QuotaAttachmentContext import com.worxbend.codeberg4s.organizations.QuotaPackage -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `QuotaUsedArtifact`, `QuotaUsedAttachment` and `QuotaUsedPackage` models — the elements of the three quota * usage listings. @@ -51,7 +51,7 @@ object QuotaArtifactDto: /** Converts a decoded array. The element path is discarded; see the object note. */ def toDomainAll(base: JsonPath, dtos: Vector[QuotaArtifactDto]): Either[DecodeFailure, Vector[QuotaArtifact]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) /** The `contained_in` object of `QuotaUsedAttachment` — an inline object in the spec with no definition of its own. */ final case class QuotaAttachmentContextDto(apiUrl: Option[String], htmlUrl: Option[String]): @@ -102,7 +102,7 @@ object QuotaAttachmentDto: /** Converts a decoded array. The element path is discarded; see the object note. */ def toDomainAll(base: JsonPath, dtos: Vector[QuotaAttachmentDto]): Either[DecodeFailure, Vector[QuotaAttachment]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) /** Forgejo's `QuotaUsedPackage` model, field for field. */ final case class QuotaPackageDto( @@ -143,4 +143,4 @@ object QuotaPackageDto: /** Converts a decoded array. The element path is discarded; see the object note. */ def toDomainAll(base: JsonPath, dtos: Vector[QuotaPackageDto]): Either[DecodeFailure, Vector[QuotaPackage]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala index 1fd64c5..c70d1e4 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.organizations.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.organizations.Organization import com.worxbend.codeberg4s.organizations.Team import com.worxbend.codeberg4s.organizations.TeamId import com.worxbend.codeberg4s.organizations.TeamPermission -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `Team` model, field for field. * @@ -104,7 +104,7 @@ object TeamDto: /** Converts a decoded array of teams, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[TeamDto]): Either[DecodeFailure, Vector[Team]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** The `units_map` object as unit name to raw level, dropping any entry whose value is not a string. */ private def rawLevels(fields: JsonFields): Map[String, String] = diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ChangedFileDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ChangedFileDto.scala index c909027..1749368 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ChangedFileDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ChangedFileDto.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.wire.WireElements import com.worxbend.codeberg4s.pulls.ChangedFile import com.worxbend.codeberg4s.repositories.CommitFileStatus @@ -80,4 +80,4 @@ object ChangedFileDto: /** Converts a decoded array of changed files, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ChangedFileDto]): Either[DecodeFailure, Vector[ChangedFile]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala index ffd98a9..ff74c6a 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.Milestone import com.worxbend.codeberg4s.issues.wire.LabelDto import com.worxbend.codeberg4s.issues.wire.MilestoneDto -import com.worxbend.codeberg4s.issues.wire.WireElements import com.worxbend.codeberg4s.pulls.PullRequest import com.worxbend.codeberg4s.pulls.PullRequestBranch import com.worxbend.codeberg4s.pulls.PullRequestNumber @@ -163,7 +163,7 @@ final case class PullRequestDto( dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) private def usersAt(at: JsonPath, field: String, dtos: Vector[UserDto]): Either[DecodeFailure, Vector[User]] = - WireElements.at(at.field(field), dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(at.field(field), dtos)((dto, path) => dto.toDomainAt(path)) private def milestoneAt(at: JsonPath): Either[DecodeFailure, Option[Milestone]] = milestone.fold(Right(None))(dto => dto.toDomainAt(at.field("milestone")).map(Some.apply)) @@ -229,4 +229,4 @@ object PullRequestDto: /** Converts a decoded array of pull requests, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[PullRequestDto]): Either[DecodeFailure, Vector[PullRequest]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala index 0e91913..0c1cf85 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.wire.WireElements import com.worxbend.codeberg4s.pulls.ReviewComment import com.worxbend.codeberg4s.pulls.ReviewCommentId import com.worxbend.codeberg4s.pulls.ReviewId @@ -131,4 +131,4 @@ object ReviewCommentDto: /** Converts a decoded array of review comments, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ReviewCommentDto]): Either[DecodeFailure, Vector[ReviewComment]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala index d4344fd..627416f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.wire.WireElements import com.worxbend.codeberg4s.pulls.Review import com.worxbend.codeberg4s.pulls.ReviewId import com.worxbend.codeberg4s.pulls.ReviewState @@ -117,4 +117,4 @@ object ReviewDto: /** Converts a decoded array of reviews, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ReviewDto]): Either[DecodeFailure, Vector[Review]] = - WireElements.at(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/BranchProtectionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/BranchProtectionDto.scala index 3a96816..a05abf5 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/BranchProtectionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/BranchProtectionDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.repositories.access.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.access.BranchProtection -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `BranchProtection` model, field for field. * @@ -150,4 +150,4 @@ object BranchProtectionDto: /** Converts a decoded array of rules, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[BranchProtectionDto]): Either[DecodeFailure, Vector[BranchProtection]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala index ffa086c..446fe8b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.access.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.access.DeployKey import com.worxbend.codeberg4s.repositories.access.DeployKeyId -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto /** The wire spelling of every property a deploy key has, written down exactly once. @@ -131,4 +131,4 @@ object DeployKeyDto: /** Converts a decoded array of deploy keys, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[DeployKeyDto]): Either[DecodeFailure, Vector[DeployKey]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/TagProtectionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/TagProtectionDto.scala index 9595bfc..37b454b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/TagProtectionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/TagProtectionDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.access.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.access.TagProtection import com.worxbend.codeberg4s.repositories.access.TagProtectionId -import com.worxbend.codeberg4s.repositories.wire.Elements /** The wire spelling of every property a tag protection rule has, written down exactly once. * @@ -98,4 +98,4 @@ object TagProtectionDto: /** Converts a decoded array of rules, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[TagProtectionDto]): Either[DecodeFailure, Vector[TagProtection]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionArtifactDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionArtifactDto.scala index 922393c..e178246 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionArtifactDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionArtifactDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionArtifact import com.worxbend.codeberg4s.repositories.actions.ArtifactId import com.worxbend.codeberg4s.repositories.actions.RunId -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `ActionArtifact` model, field for field. * @@ -91,4 +91,4 @@ object ActionArtifactDto: /** Converts a decoded array of artifacts, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionArtifactDto]): Either[DecodeFailure, Vector[ActionArtifact]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala index 2572de1..cefefd9 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionRun import com.worxbend.codeberg4s.repositories.actions.RunId -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -152,7 +152,7 @@ object ActionRunDto: /** Converts a decoded array of runs, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionRunDto]): Either[DecodeFailure, Vector[ActionRun]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** A Go `time.Duration` as a [[scala.concurrent.duration.FiniteDuration]]. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunJobDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunJobDto.scala index 721ac08..66a59fd 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunJobDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunJobDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.repositories.actions.ActionRunJob import com.worxbend.codeberg4s.repositories.actions.JobAttempt import com.worxbend.codeberg4s.repositories.actions.JobId import com.worxbend.codeberg4s.repositories.actions.RunId -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `ActionRunJob` model, field for field. * @@ -92,4 +92,4 @@ object ActionRunJobDto: /** Converts a decoded array of jobs, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionRunJobDto]): Either[DecodeFailure, Vector[ActionRunJob]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunnerDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunnerDto.scala index e1a89b5..2260b14 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunnerDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunnerDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionRunner import com.worxbend.codeberg4s.repositories.actions.RunnerId import com.worxbend.codeberg4s.repositories.actions.RunnerStatus -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `ActionRunner` model, field for field. * @@ -83,4 +83,4 @@ object ActionRunnerDto: /** Converts a decoded array of runners, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionRunnerDto]): Either[DecodeFailure, Vector[ActionRunner]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionSecretDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionSecretDto.scala index 117c05e..dc6757b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionSecretDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionSecretDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionSecret import com.worxbend.codeberg4s.repositories.actions.SecretName -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `Secret` model — two keys, and neither of them is the secret. * @@ -57,4 +57,4 @@ object ActionSecretDto: /** Converts a decoded array of secrets, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionSecretDto]): Either[DecodeFailure, Vector[ActionSecret]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionTaskDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionTaskDto.scala index e7aed89..8629782 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionTaskDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionTaskDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionTask import com.worxbend.codeberg4s.repositories.actions.TaskId -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `ActionTask` model, field for field. * @@ -89,4 +89,4 @@ object ActionTaskDto: /** Converts a decoded array of tasks, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionTaskDto]): Either[DecodeFailure, Vector[ActionTask]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionVariableDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionVariableDto.scala index 27a43ad..311d413 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionVariableDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionVariableDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.repositories.actions.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionVariable import com.worxbend.codeberg4s.repositories.actions.VariableName -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `ActionVariable` model, field for field. * @@ -69,4 +69,4 @@ object ActionVariableDto: /** Converts a decoded array of variables, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[ActionVariableDto]): Either[DecodeFailure, Vector[ActionVariable]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala index b018755..de654a5 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -12,7 +13,6 @@ import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.ActivityId import com.worxbend.codeberg4s.repositories.admin.ActivityOperation import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -142,4 +142,4 @@ object ActivityDto: /** Converts a whole array, each element failing at its own index. */ def toDomainAll(base: JsonPath, dtos: Vector[ActivityDto]): Either[DecodeFailure, Vector[RepositoryActivity]] = - Elements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) + ArrayElements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala index 668ac43..fe31943 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.core.DecodeFailure @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.repositories.admin.FileChangeSet import com.worxbend.codeberg4s.repositories.gitdata.FileCommit import com.worxbend.codeberg4s.repositories.gitdata.wire.FileCommitDto import com.worxbend.codeberg4s.repositories.wire.ContentEntryDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.VerificationDto /** Forgejo's `FilesResponse` — what `POST /repos/{owner}/{repo}/contents` answers with. @@ -42,7 +42,7 @@ final case class FilesResponseDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, FileChangeSet] = for written <- commitAt(at) - entries <- Elements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) + entries <- ArrayElements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) yield FileChangeSet(commit = written, files = entries, verification = verification.map(_.toDomain)) /** [[toDomainAt]] for a payload that is the whole response body. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/PushMirrorDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/PushMirrorDto.scala index 6da5d9d..3d2e14f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/PushMirrorDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/PushMirrorDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.admin.MirrorName import com.worxbend.codeberg4s.repositories.admin.PushMirror -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `PushMirror` model, field for field. * @@ -110,4 +110,4 @@ object PushMirrorDto: /** Converts a whole array, each element failing at its own index. */ def toDomainAll(base: JsonPath, dtos: Vector[PushMirrorDto]): Either[DecodeFailure, Vector[PushMirror]] = - Elements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) + ArrayElements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/TopicSummaryDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/TopicSummaryDto.scala index 45919a2..912625b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/TopicSummaryDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/TopicSummaryDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.admin.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.admin.TopicId import com.worxbend.codeberg4s.repositories.admin.TopicSummary -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `TopicResponse` model, field for field. * @@ -75,7 +75,7 @@ object TopicSummaryDto: /** Converts a whole array, each element failing at its own index. */ def toDomainAll(base: JsonPath, dtos: Vector[TopicSummaryDto]): Either[DecodeFailure, Vector[TopicSummary]] = - Elements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) + ArrayElements.convert(base, dtos)((dto, at) => dto.toDomainAt(at)) /** The `{"topics": [...]}` envelope `GET /topics/search` answers with. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala index dae6be8..9ea8e1f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.gitdata.CombinedCommitStatus import com.worxbend.codeberg4s.repositories.gitdata.CommitStatusState -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.RepositoryDto /** Forgejo's `CombinedStatus` model, field for field. @@ -52,7 +52,7 @@ final case class CombinedStatusDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, CombinedCommitStatus] = for commit <- Wire.validated(at, "sha", sha)(CommitSha.from) - reported <- Elements.convert(at.field("statuses"), statuses)((dto, path) => dto.toDomainAt(path)) + reported <- ArrayElements.convert(at.field("statuses"), statuses)((dto, path) => dto.toDomainAt(path)) repo <- repositoryAt(at) yield CombinedCommitStatus( sha = commit, diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CompareDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CompareDto.scala index a8f42dd..fe84694 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CompareDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CompareDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.gitdata.CommitComparison import com.worxbend.codeberg4s.repositories.wire.CommitAffectedFileDto import com.worxbend.codeberg4s.repositories.wire.CommitDto -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `Compare` model — three keys, all of them arrays or counts. * @@ -35,8 +35,8 @@ final case class CompareDto( */ def toDomainAt(at: JsonPath): Either[DecodeFailure, CommitComparison] = for - history <- Elements.convert(at.field("commits"), commits)((dto, path) => dto.toDomainAt(path)) - changed <- Elements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) + history <- ArrayElements.convert(at.field("commits"), commits)((dto, path) => dto.toDomainAt(path)) + changed <- ArrayElements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) yield CommitComparison(totalCommits = totalCommits.getOrElse(0L), commits = history, files = changed) /** [[toDomainAt]] for a payload that is the whole response body. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala index 27a3243..5093d81 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.repositories.CommitRef import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.gitdata.FileCommit import com.worxbend.codeberg4s.repositories.wire.CommitMetaDto -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.GitIdentityDto /** Forgejo's `FileCommitResponse` — the commit an editing endpoint reports it wrote. @@ -59,7 +59,7 @@ final case class FileCommitDto( for identifier <- Wire.validated(at, "sha", sha)(CommitSha.from) root <- treeAt(at) - ancestors <- Elements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) + ancestors <- ArrayElements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) yield FileCommit( sha = identifier, message = message, diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitTreeDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitTreeDto.scala index 6688b73..31b7921 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitTreeDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/GitTreeDto.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.gitdata.GitTreeEntry -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `GitTreeResponse` — the envelope `GET /repos/{owner}/{repo}/git/trees/{sha}` wraps its entries in. * @@ -47,7 +47,7 @@ final case class GitTreeDto( * instance omitted is still a usable list of entries. */ def toDomain: Either[DecodeFailure, Vector[GitTreeEntry]] = - Elements.convert(JsonPath.Root.field("tree"), entries)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(JsonPath.Root.field("tree"), entries)((dto, path) => dto.toDomainAt(path)) object GitTreeDto: diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/GitHookDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/GitHookDto.scala index 66f6981..cd45739 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/GitHookDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/GitHookDto.scala @@ -1,13 +1,13 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.hooks.GitHook import com.worxbend.codeberg4s.repositories.hooks.GitHookName -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `GitHook` model — one script the instance runs when a push arrives. * @@ -59,4 +59,4 @@ object GitHookDto: /** Converts a decoded array of Git hooks, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[GitHookDto]): Either[DecodeFailure, Vector[GitHook]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueConfigDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueConfigDto.scala index ca2538c..14128ab 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueConfigDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueConfigDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.hooks.IssueConfig import com.worxbend.codeberg4s.repositories.hooks.IssueConfigValidation import com.worxbend.codeberg4s.repositories.hooks.IssueContactLink -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `IssueConfigContactLink` model — one alternative to opening an issue. * @@ -66,7 +66,7 @@ final case class IssueConfigDto( * receives a link with a missing half. */ def toDomainAt(at: JsonPath): Either[DecodeFailure, IssueConfig] = - Elements + ArrayElements .convert(at.field(IssueConfigDto.ContactLinksKey), contactLinks)((dto, path) => dto.toDomainAt(path)) .map(links => IssueConfig(blankIssuesEnabled = blankIssuesEnabled, contactLinks = links)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueTemplateDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueTemplateDto.scala index 4d1c9f9..f62d387 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueTemplateDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/IssueTemplateDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -8,7 +9,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.hooks.IssueFormField import com.worxbend.codeberg4s.repositories.hooks.IssueFormFieldType import com.worxbend.codeberg4s.repositories.hooks.IssueTemplate -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `IssueFormField` model — one control of an issue form template. * @@ -126,4 +126,4 @@ object IssueTemplateDto: /** Converts a decoded array of templates, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[IssueTemplateDto]): Either[DecodeFailure, Vector[IssueTemplate]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/RepositoryFlagWire.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/RepositoryFlagWire.scala index 3538857..5020ccf 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/RepositoryFlagWire.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/RepositoryFlagWire.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.hooks.RepositoryFlag -import com.worxbend.codeberg4s.repositories.wire.Elements /** The two directions of the repository flag routes, which have no model of their own. * @@ -29,7 +29,7 @@ private[codeberg4s] object RepositoryFlagWire: * list would hide that. */ def toDomainAll(base: JsonPath, values: Vector[String]): Either[DecodeFailure, Vector[RepositoryFlag]] = - Elements.convert(base, values): (value, path) => + ArrayElements.convert(base, values): (value, path) => RepositoryFlag.from(value).left.map(error => DecodeFailure(path, error.message)) /** Renders `flags` as the JSON body of `PUT /repos/{owner}/{repo}/flags`. diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WebhookDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WebhookDto.scala index c918028..f5ab7e6 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WebhookDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WebhookDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.repositories.hooks.HookConfig import com.worxbend.codeberg4s.repositories.hooks.HookId import com.worxbend.codeberg4s.repositories.hooks.HookType import com.worxbend.codeberg4s.repositories.hooks.Webhook -import com.worxbend.codeberg4s.repositories.wire.Elements /** Forgejo's `Hook` model — one repository webhook. * @@ -107,4 +107,4 @@ object WebhookDto: /** Converts a decoded array of hooks, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[WebhookDto]): Either[DecodeFailure, Vector[Webhook]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala index afca595..41289e4 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire @@ -10,7 +11,6 @@ import com.worxbend.codeberg4s.repositories.FileContent import com.worxbend.codeberg4s.repositories.hooks.WikiCommit import com.worxbend.codeberg4s.repositories.hooks.WikiPage import com.worxbend.codeberg4s.repositories.hooks.WikiPageMeta -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.repositories.wire.GitIdentityDto /** Forgejo's `WikiCommit` model — one revision of a wiki page. @@ -76,7 +76,7 @@ object WikiCommitDto: /** Converts a decoded array of revisions, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[WikiCommitDto]): Either[DecodeFailure, Vector[WikiCommit]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** Forgejo's `WikiPage` model — one wiki page with its content. * @@ -202,7 +202,7 @@ object WikiPageMetaDto: /** Converts a decoded array of listing entries, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[WikiPageMetaDto]): Either[DecodeFailure, Vector[WikiPageMeta]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** Forgejo's `WikiCommitList` model — the envelope the revision listing returns. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala index 0b5302e..1bb0e6e 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -65,8 +66,8 @@ final case class CommitDto( details <- detailsAt(at) authorUser <- userAt(at, "author", author) committerUser <- userAt(at, "committer", committer) - parentRefs <- Elements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) - changed <- Elements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) + parentRefs <- ArrayElements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) + changed <- ArrayElements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) yield Commit( sha = identifier, url = url, diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/Elements.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/Elements.scala deleted file mode 100644 index 3d3a5f8..0000000 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/Elements.scala +++ /dev/null @@ -1,38 +0,0 @@ -package com.worxbend.codeberg4s.repositories.wire - -import com.worxbend.codeberg4s.JsonPath -import com.worxbend.codeberg4s.codec.ArrayElements -import com.worxbend.codeberg4s.core.DecodeFailure - -/** Converting a JSON array of DTOs into domain values, with each failure reported at its own index. - * - * Rule 5 of [[com.worxbend.codeberg4s.codec.WireConventions]] says a `toDomain` reports the JSON path of whatever it - * could not convert. For an array that means `$[7].sha` and not `$`, and getting there requires turning each element's - * position into a path segment. Doing it once here is what stops every list-shaped model from growing its own copy — - * and a copy that quietly forgot the index would be indistinguishable from one that did not, until someone tried to - * debug a bad payload. - * - * The walk itself lives in [[com.worxbend.codeberg4s.codec.ArrayElements]]; this adds the path. - * - * Used from two sides: by the DTOs below for arrays nested inside a model — a commit's parents, a release's assets — - * and by the client module for a response body that is an array at the top level. - */ -private[codeberg4s] object Elements: - - /** Converts every element, stopping at the first failure. - * - * One bad element fails the whole array, which is the same contract a bare list body already has: a caller asking - * for a page of commits cannot act on "forty-nine of the fifty decoded". - * - * @param at - * the path of the array itself — [[com.worxbend.codeberg4s.JsonPath.Root]] for a response body that is an array, - * the field's path for an array nested in an object - * @param dtos - * the decoded elements, in wire order - * @param one - * converts a single element, given the path that element sits at - */ - def convert[D, A](at: JsonPath, dtos: Vector[D])( - one: (D, JsonPath) => Either[DecodeFailure, A] - ): Either[DecodeFailure, Vector[A]] = - ArrayElements.convert(dtos)((dto, position) => one(dto, at.index(position))) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala index a7b2342..f6efc99 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -86,7 +87,7 @@ final case class ReleaseDto( identifier <- Wire.validated(at, "id", id)(ReleaseId.from) tag <- Wire.validated(at, "tag_name", tagName)(TagName.from) publisher <- authorAt(at) - attached <- Elements.convert(at.field("assets"), assets)((dto, path) => dto.toDomainAt(path)) + attached <- ArrayElements.convert(at.field("assets"), assets)((dto, path) => dto.toDomainAt(path)) yield Release( id = identifier, tagName = tag, diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryContentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryContentDto.scala index 6a90335..7c49b32 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryContentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryContentDto.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.JsonValue @@ -46,7 +47,7 @@ enum RepositoryContentDto: case Single(entry) => entry.toDomainAt(JsonPath.Root).map(RepositoryContent.File.apply) case Listing(entries) => - Elements + ArrayElements .convert(JsonPath.Root, entries)((dto, path) => dto.toDomainAt(path)) .map(RepositoryContent.Directory.apply) case Unexpected(shape) => diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/EmailDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/EmailDto.scala index 81f8913..7bd06bc 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/EmailDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/EmailDto.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.users.account.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.account.Email import com.worxbend.codeberg4s.users.account.EmailAddress @@ -70,4 +70,4 @@ object EmailDto: /** Converts a decoded array of addresses, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[EmailDto]): Either[DecodeFailure, Vector[Email]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/OAuth2ApplicationDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/OAuth2ApplicationDto.scala index eb4040e..09acc11 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/OAuth2ApplicationDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/OAuth2ApplicationDto.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.users.account.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.account.ClientSecret import com.worxbend.codeberg4s.users.account.OAuth2Application import com.worxbend.codeberg4s.users.account.OAuth2ApplicationId @@ -95,4 +95,4 @@ object OAuth2ApplicationDto: base: JsonPath, dtos: Vector[OAuth2ApplicationDto], ): Either[DecodeFailure, Vector[OAuth2Application]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/QuotaUsageDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/QuotaUsageDto.scala index 90bfdc5..f2a61b3 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/QuotaUsageDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/account/wire/QuotaUsageDto.scala @@ -1,10 +1,10 @@ package com.worxbend.codeberg4s.users.account.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.account.AttachmentContainer import com.worxbend.codeberg4s.users.account.QuotaUsedArtifact import com.worxbend.codeberg4s.users.account.QuotaUsedAttachment @@ -23,8 +23,8 @@ import com.worxbend.codeberg4s.users.account.QuotaUsedPackage * report a caller is reading to find out why they are over quota. * * That is also why these models have no `toDomainAt`: a path exists to say where a conversion failed, and none of - * these can. The array conversions still go through [[com.worxbend.codeberg4s.repositories.wire.Elements.convert]] so - * that the day one of these fields becomes load-bearing, the position reporting is already in place. + * these can. The array conversions still go through [[com.worxbend.codeberg4s.codec.ArrayElements.convert]] so that + * the day one of these fields becomes load-bearing, the position reporting is already in place. */ final case class QuotaUsedArtifactDto( name: Option[String], @@ -57,7 +57,7 @@ object QuotaUsedArtifactDto: base: JsonPath, dtos: Vector[QuotaUsedArtifactDto], ): Either[DecodeFailure, Vector[QuotaUsedArtifact]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) /** Forgejo's `QuotaUsedAttachment` model — one attachment counting towards the quota. * @@ -120,7 +120,7 @@ object QuotaUsedAttachmentDto: base: JsonPath, dtos: Vector[QuotaUsedAttachmentDto], ): Either[DecodeFailure, Vector[QuotaUsedAttachment]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) /** Forgejo's `QuotaUsedPackage` model — one package version counting towards the quota. * @@ -168,4 +168,4 @@ object QuotaUsedPackageDto: base: JsonPath, dtos: Vector[QuotaUsedPackageDto], ): Either[DecodeFailure, Vector[QuotaUsedPackage]] = - Elements.convert(base, dtos)((dto, _) => dto.toDomain) + ArrayElements.convert(base, dtos)((dto, _) => dto.toDomain) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/AccessTokenDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/AccessTokenDto.scala index 763c548..dcb38f9 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/AccessTokenDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/AccessTokenDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.auth.ApiToken +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps @@ -9,7 +10,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.wire.RepositoryMetaDto import com.worxbend.codeberg4s.repositories.RepoSlug -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.AccessToken import com.worxbend.codeberg4s.users.social.AccessTokenId import com.worxbend.codeberg4s.users.social.AccessTokenName @@ -155,4 +155,4 @@ object AccessTokenDto: /** Converts a decoded array of tokens, reporting the position of whichever element failed. Never reads `sha1`. */ def toDomainAll(base: JsonPath, dtos: Vector[AccessTokenDto]): Either[DecodeFailure, Vector[AccessToken]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/BlockedUserDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/BlockedUserDto.scala index 989770f..3f4df0e 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/BlockedUserDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/BlockedUserDto.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.BlockId import com.worxbend.codeberg4s.users.social.BlockedUser @@ -55,4 +55,4 @@ object BlockedUserDto: /** Converts a decoded array of entries, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[BlockedUserDto]): Either[DecodeFailure, Vector[BlockedUser]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/GpgKeyDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/GpgKeyDto.scala index dd9a466..d33ff69 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/GpgKeyDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/GpgKeyDto.scala @@ -1,12 +1,12 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.GpgKey import com.worxbend.codeberg4s.users.social.GpgKeyEmail import com.worxbend.codeberg4s.users.social.GpgKeyId @@ -131,15 +131,15 @@ final case class GpgKeyDto( * * '''`emails` and `subkeys` are not lenient.''' Each element is converted at its own path, so a failure says * `$.emails[1].email` or `$.subkeys[0].id`, and one bad element fails the key — the contract every list in this - * library has, for the reason [[com.worxbend.codeberg4s.repositories.wire.Elements]] states. + * library has, for the reason [[com.worxbend.codeberg4s.codec.ArrayElements]] states. * * Every flag absent reads as `false`, which grants the fewest capabilities. */ def toDomainAt(at: JsonPath): Either[DecodeFailure, GpgKey] = for identifier <- Wire.validated(at, "id", id)(GpgKeyId.from) - addresses <- Elements.convert(at.field("emails"), emails)((dto, path) => dto.toDomainAt(path)) - children <- Elements.convert(at.field("subkeys"), subkeys)((dto, path) => dto.toDomainAt(path)) + addresses <- ArrayElements.convert(at.field("emails"), emails)((dto, path) => dto.toDomainAt(path)) + children <- ArrayElements.convert(at.field("subkeys"), subkeys)((dto, path) => dto.toDomainAt(path)) yield GpgKey( id = identifier, keyId = keyId.flatMap(value => OpenPgpKeyId.from(value).toOption), @@ -191,4 +191,4 @@ object GpgKeyDto: /** Converts a decoded array of keys, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[GpgKeyDto]): Either[DecodeFailure, Vector[GpgKey]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/HeatmapEntryDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/HeatmapEntryDto.scala index 3df52bd..b6ce886 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/HeatmapEntryDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/HeatmapEntryDto.scala @@ -1,11 +1,11 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.HeatmapEntry import java.time.Instant @@ -67,4 +67,4 @@ object HeatmapEntryDto: /** Converts a decoded array of buckets, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[HeatmapEntryDto]): Either[DecodeFailure, Vector[HeatmapEntry]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala index 8687f16..0fb295d 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/StopWatchDto.scala @@ -3,13 +3,13 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.RepoSlug -import com.worxbend.codeberg4s.repositories.wire.Elements import com.worxbend.codeberg4s.users.social.StopWatch import scala.concurrent.duration.DurationLong @@ -119,7 +119,7 @@ object StopWatchDto: /** Converts a decoded array of stopwatches, reporting the position of whichever element failed. */ def toDomainAll(base: JsonPath, dtos: Vector[StopWatchDto]): Either[DecodeFailure, Vector[StopWatch]] = - Elements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) + ArrayElements.convert(base, dtos)((dto, path) => dto.toDomainAt(path)) /** The wire's seconds as a duration. One line, in one place, so the unit is never re-derived. */ def asDuration(seconds: Long): FiniteDuration = diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/BranchDtoSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/BranchDtoSuite.scala index 82ea5bd..9c7fa2a 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/BranchDtoSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/BranchDtoSuite.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.GoldenFixtures import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.repositories.Branch @@ -121,6 +122,6 @@ final class BranchDtoSuite extends FunSuite with GoldenFixtures: case Left(failure) => fail(s"$fixture did not convert: ${failure.path.render} ${failure.message}") private def list(fixture: String): Vector[Branch] = - Elements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match + ArrayElements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match case Right(branches) => branches case Left(failure) => fail(s"$fixture did not convert: ${failure.path.render} ${failure.message}") diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/CommitDtoSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/CommitDtoSuite.scala index 37309b3..c1ea3e0 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/CommitDtoSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/CommitDtoSuite.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.GoldenFixtures import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.repositories.Commit @@ -77,7 +78,7 @@ final class CommitDtoSuite extends FunSuite with GoldenFixtures: test("a listing failure is reported at the element's index"): val dtos = Vector(empty.copy(sha = Some(HeadSha)), empty) - val failure = Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)).swap.toOption + val failure = ArrayElements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)).swap.toOption assertEquals(failure.map(_.path.render), Some("$[1].sha")) @@ -90,6 +91,6 @@ final class CommitDtoSuite extends FunSuite with GoldenFixtures: case Left(failure) => fail(s"$fixture did not decode: ${failure.path.render} ${failure.message}") private def domain(fixture: String): Vector[Commit] = - Elements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match + ArrayElements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match case Right(commits) => commits case Left(failure) => fail(s"$fixture did not convert: ${failure.path.render} ${failure.message}") diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDtoSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDtoSuite.scala index 32f7915..b2cbf78 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDtoSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDtoSuite.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.GoldenFixtures import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.repositories.ArchiveDownloadCount @@ -109,6 +110,6 @@ final class ReleaseDtoSuite extends FunSuite with GoldenFixtures: Json.decode[Vector[ReleaseDto]](golden(fixture)) match case Left(failure) => fail(s"$fixture did not decode: ${failure.path.render} ${failure.message}") case Right(dtos) => - Elements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) match + ArrayElements.convert(JsonPath.Root, dtos)((dto, at) => dto.toDomainAt(at)) match case Right(releases) => releases case Left(failure) => fail(s"$fixture did not convert: ${failure.path.render} ${failure.message}") diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/TagDtoSuite.scala b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/TagDtoSuite.scala index b06433f..31aba5e 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/TagDtoSuite.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/repositories/wire/TagDtoSuite.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath +import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.GoldenFixtures import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.repositories.ArchiveDownloadCount @@ -65,6 +66,6 @@ final class TagDtoSuite extends FunSuite with GoldenFixtures: case Left(failure) => fail(s"$fixture did not decode: ${failure.path.render} ${failure.message}") private def domain(fixture: String): Vector[Tag] = - Elements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match + ArrayElements.convert(JsonPath.Root, decodeList(fixture))((dto, at) => dto.toDomainAt(at)) match case Right(tags) => tags case Left(failure) => fail(s"$fixture did not convert: ${failure.path.render} ${failure.message}") From 774bcd9ffa746775abbbed0e126b5371a9a64087 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:26:59 +0300 Subject: [PATCH 22/31] refactor(codec): promote the optional-field conversion into Wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Wire` already covered two of the three ways a DTO field reaches the domain: demand it, or demand it and run it through a smart constructor. The third — keep absence, but fail on a value the constructor rejects — lived in `pulls.wire.PullWire`, reachable only from the pulls package even though nothing about it is specific to pull requests. Moving it to `Wire.optional` puts all three shapes in one place, so a DTO in any endpoint group can express "legitimately absent, and a present-but-unparseable value is an error" without either re-inventing the match or, worse, reaching for `Wire.validated` and turning an absent field into a decoding failure. `PullWire` had no other member, so it is gone. --- .../com/worxbend/codeberg4s/codec/Wire.scala | 32 ++++++++++++++-- .../pulls/wire/PullRequestBranchDto.scala | 3 +- .../pulls/wire/PullRequestDto.scala | 4 +- .../codeberg4s/pulls/wire/PullWire.scala | 38 ------------------- .../pulls/wire/ReviewCommentDto.scala | 12 +++--- .../codeberg4s/pulls/wire/ReviewDto.scala | 4 +- 6 files changed, 41 insertions(+), 52 deletions(-) delete mode 100644 modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullWire.scala diff --git a/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala index 325e26e..1e1146d 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala @@ -7,9 +7,9 @@ import com.worxbend.codeberg4s.core.DecodeFailure /** The `wire → domain` half of a DTO conversion. * * Rule 2 of [[WireConventions]] makes every DTO field optional, so every `toDomain` has the same shape: name the - * handful of fields the domain genuinely cannot do without, and report the first one that is missing. These two - * helpers are that report, so the wording and the path construction are identical across models instead of being - * re-invented per DTO. + * handful of fields the domain genuinely cannot do without, and report the first one that is missing. These helpers + * are that report, so the wording and the path construction are identical across models instead of being re-invented + * per DTO. * * A path passed here is the path of the '''enclosing model''' inside the response document — `JsonPath.Root` for a * top-level object, `JsonPath.Root.index(2).field("owner")` for the owner of the third element of a list. Every DTO @@ -42,3 +42,29 @@ object Wire: present <- required(at, field, value) validated <- construct(present).left.map(error => DecodeFailure(at.field(field), error.message)) yield validated + + /** Runs an optional field through a smart constructor, keeping absence and rejecting a bad value. + * + * The third combination [[required]] and [[validated]] leave open, and the one a field that is '''legitimately + * absent''' needs: a pull request's `merge_commit_sha` is missing after a fast-forward merge, and a review's + * `commit_id` pins no commit until the review is submitted. Absence there is information. A value that is present + * but that the constructor rejects is not — it means the payload is not what it claims to be — so it fails rather + * than quietly becoming `None`. + * + * @param at + * the path of the model being converted + * @param field + * the '''wire''' (snake_case) field name, so the message matches what a reader sees in the payload + * @return + * `None` when the field was absent, the constructed value when it was present and valid, and a failure at + * `at.field(field)` when it was present and rejected + */ + def optional[A, B](at: JsonPath, field: String, value: Option[A])( + construct: A => Either[ValidationError, B] + ): Either[DecodeFailure, Option[B]] = + value match + case None => Right(None) + case Some(present) => + construct(present) match + case Right(built) => Right(Some(built)) + case Left(error) => Left(DecodeFailure(at.field(field), error.message)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala index cc1bdfc..a6d2e61 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields +import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.pulls.PullRequestBranch import com.worxbend.codeberg4s.repositories.BranchName @@ -54,7 +55,7 @@ final case class PullRequestBranchDto( */ def toDomainAt(at: JsonPath): Either[DecodeFailure, PullRequestBranch] = for - tip <- PullWire.optional(at, "sha", sha)(CommitSha.from) + tip <- Wire.optional(at, "sha", sha)(CommitSha.from) repository <- repositoryAt(at) yield PullRequestBranch( label = label, diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala index ff74c6a..57bb82e 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala @@ -105,8 +105,8 @@ final case class PullRequestDto( headline <- Wire.required(at, "title", title) author <- userAt(at, "user", user) merger <- userAt(at, "merged_by", mergedBy) - mergeSha <- PullWire.optional(at, "merge_commit_sha", mergeCommitSha)(CommitSha.from) - ancestor <- PullWire.optional(at, "merge_base", mergeBase)(CommitSha.from) + mergeSha <- Wire.optional(at, "merge_commit_sha", mergeCommitSha)(CommitSha.from) + ancestor <- Wire.optional(at, "merge_base", mergeBase)(CommitSha.from) lifecycle <- Wire.validated(at, "state", state)(value => PullRequestState.from( state = value, diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullWire.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullWire.scala deleted file mode 100644 index a5f9b94..0000000 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullWire.scala +++ /dev/null @@ -1,38 +0,0 @@ -package com.worxbend.codeberg4s.pulls.wire - -import com.worxbend.codeberg4s.JsonPath -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.core.DecodeFailure - -/** The one conversion shape [[com.worxbend.codeberg4s.codec.Wire]] does not already cover. - * - * `Wire.required` demands a field, and `Wire.validated` demands it and runs it through a smart constructor. This group - * needs the third combination four times over — `base.sha`, `head.sha`, `merge_base`, `merge_commit_sha` and a - * review's `commit_id` are all fields that are '''legitimately absent''' and, when present, must be a real - * [[com.worxbend.codeberg4s.repositories.CommitSha]] rather than quietly dropped. - * - * The distinction matters: absence is information (a fast-forward merge produces no merge commit, a review request - * pins no commit), whereas a present-but-unparseable object id means the payload is not what it claims to be, and - * silently answering `None` there would hide it. - */ -private[pulls] object PullWire: - - /** Runs an optional field through a smart constructor, keeping absence and rejecting a bad value. - * - * @param at - * the path of the model being converted - * @param field - * the '''wire''' (snake_case) field name, so the message matches what a reader sees in the payload - * @return - * `None` when the field was absent, the constructed value when it was present and valid, and a failure at - * `at.field(field)` when it was present and rejected - */ - def optional[A, B](at: JsonPath, field: String, value: Option[A])( - construct: A => Either[ValidationError, B] - ): Either[DecodeFailure, Option[B]] = - value match - case None => Right(None) - case Some(present) => - construct(present) match - case Right(built) => Right(Some(built)) - case Left(error) => Left(DecodeFailure(at.field(field), error.message)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala index 0c1cf85..2928326 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala @@ -27,9 +27,9 @@ import com.worxbend.codeberg4s.users.wire.UserDto * * ==The two commit ids are strict, the review id is not== * - * `commit_id` and `original_commit_id` go through [[PullWire.optional]]: legitimately absent, and a - * present-but-unparseable object id is reported at its own path rather than dropped, for the reason [[PullWire]] - * gives. + * `commit_id` and `original_commit_id` go through [[com.worxbend.codeberg4s.codec.Wire.optional]]: legitimately + * absent, and a present-but-unparseable object id is reported at its own path rather than dropped, for the reason + * [[com.worxbend.codeberg4s.codec.Wire.optional]] gives. * * `pull_request_review_id` is deliberately treated differently. Forgejo's Go struct types it as a plain `int64` with * no `omitempty`, so a comment that is not yet attached to a submitted review serialises it as `0` — which @@ -64,9 +64,9 @@ final case class ReviewCommentDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, ReviewComment] = for identifier <- Wire.validated(at, "id", id)(ReviewCommentId.from) - review <- PullWire.optional(at, "pull_request_review_id", pullRequestReviewId.filter(_ > 0L))(ReviewId.from) - pinned <- PullWire.optional(at, "commit_id", commitId)(CommitSha.from) - original <- PullWire.optional(at, "original_commit_id", originalCommitId)(CommitSha.from) + review <- Wire.optional(at, "pull_request_review_id", pullRequestReviewId.filter(_ > 0L))(ReviewId.from) + pinned <- Wire.optional(at, "commit_id", commitId)(CommitSha.from) + original <- Wire.optional(at, "original_commit_id", originalCommitId)(CommitSha.from) author <- userAt(at, "user", user) resolvedBy <- userAt(at, "resolver", resolver) yield ReviewComment( diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala index 627416f..187c730 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala @@ -51,7 +51,7 @@ final case class ReviewDto( * * Only `id` is required, and it goes through [[com.worxbend.codeberg4s.pulls.ReviewId.from]] because it is the only * way to address a review. `commit_id` is strict when present — a non-hexadecimal object id is reported at - * `$.commit_id` rather than dropped — for the reason [[PullWire]] gives. + * `$.commit_id` rather than dropped — for the reason [[com.worxbend.codeberg4s.codec.Wire.optional]] gives. * * `state` is the deliberately '''lenient''' field: a spelling [[com.worxbend.codeberg4s.pulls.ReviewState.parse]] * does not recognise becomes `None` instead of failing the review, which is also what happens to the `""` Forgejo @@ -62,7 +62,7 @@ final case class ReviewDto( for identifier <- Wire.validated(at, "id", id)(ReviewId.from) reviewer <- authorAt(at) - pinned <- PullWire.optional(at, "commit_id", commitId)(CommitSha.from) + pinned <- Wire.optional(at, "commit_id", commitId)(CommitSha.from) yield Review( id = identifier, state = state.flatMap(ReviewState.parse), From aff3f7ccb50a9d637aded727b93720e853211a2e Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:28:54 +0300 Subject: [PATCH 23/31] refactor(codec): move the instant renderer into Timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues.wire.WireInstant` rendered an outgoing timestamp in the one RFC-3339 spelling Forgejo's Go parser accepts. Its own note said it should move into `codec` as soon as a second endpoint group needed to send a timestamp; six groups now do — pulls, notifications, user social, git data and repository administration all imported it out of the issues package, which read as an accident rather than a decision. It becomes `Timestamps.render`, next to the `Timestamps.parse` it is the counterpart of, so reading and writing a Forgejo timestamp are one place. The rendering itself is unchanged: `Z` offset, second precision. --- .../worxbend/codeberg4s/issues/IssueApi.scala | 2 +- .../codeberg4s/codec/Timestamps.scala | 17 ++++++++++++ .../issues/wire/AddTimeOptionDto.scala | 3 ++- .../issues/wire/CreateIssueOptionDto.scala | 3 ++- .../issues/wire/EditDeadlineOptionDto.scala | 7 ++--- .../wire/EditIssueCommentOptionDto.scala | 3 ++- .../issues/wire/EditIssueOptionDto.scala | 3 ++- .../issues/wire/IssueLabelsOptionDto.scala | 5 ++-- .../codeberg4s/issues/wire/IssueQueries.scala | 27 ++++++++++--------- .../issues/wire/MilestoneOptionDto.scala | 5 ++-- .../codeberg4s/issues/wire/WireInstant.scala | 25 ----------------- .../wire/NotificationQueries.scala | 8 +++--- .../wire/CreatePullRequestOptionDto.scala | 6 ++--- .../pulls/wire/EditPullRequestOptionDto.scala | 4 +-- .../admin/wire/FileOptionsDto.scala | 6 ++--- .../gitdata/wire/DiffPatchOptionsDto.scala | 6 ++--- .../users/social/wire/SocialQueries.scala | 10 +++---- 17 files changed, 70 insertions(+), 70 deletions(-) delete mode 100644 modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireInstant.scala diff --git a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala index 89d258f..5f251f3 100644 --- a/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala +++ b/modules/client/src/com/worxbend/codeberg4s/issues/IssueApi.scala @@ -317,7 +317,7 @@ final class IssueApi private[codeberg4s] (pipeline: ApiPipeline[Future])(using e * '''Answers `201`''' with the deadline the instance now holds; see [[IssueDeadline]]. * * '''Failures.''' The group contract above. A `422` carrying a raw Go parse error is what a timestamp Forgejo cannot - * read produces (`docs/HAZARDS.md` §4), though [[com.worxbend.codeberg4s.issues.wire.WireInstant]] renders one it + * read produces (`docs/HAZARDS.md` §4), though [[com.worxbend.codeberg4s.codec.Timestamps.render]] renders one it * can. */ def setDeadline(owner: Owner, name: RepoName, number: IssueNumber, dueDate: Instant): Future[IssueDeadline] = diff --git a/modules/codec/src/com/worxbend/codeberg4s/codec/Timestamps.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/Timestamps.scala index 324f934..93099d8 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/codec/Timestamps.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/Timestamps.scala @@ -6,6 +6,8 @@ import scala.util.Try import java.time.Instant import java.time.OffsetDateTime import java.time.Year +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit /** Turns Forgejo's timestamp strings into instants, sentinels included. * @@ -17,6 +19,8 @@ import java.time.Year * `"0001-01-01T00:00:00Z"` rather than `null`, and `Repository.archived_at` comes back as the Unix epoch * (`"1970-01-01T01:00:00+01:00"`) on repositories that were never archived. Both are absence wearing a costume, and * both are folded into `None` here so no caller has to know the trick. + * + * The reverse direction lives here too: [[render]] writes the one spelling Forgejo's parser accepts. */ object Timestamps: @@ -43,6 +47,19 @@ object Timestamps: def parseOptional(value: Option[String]): Option[Instant] = value.flatMap(parse) + /** `value` as RFC-3339 with a `Z` offset and second precision, for example `"2026-08-01T18:14:16Z"`. + * + * The counterpart of [[parse]]. Reading is lenient and writing cannot be: Forgejo parses timestamps with Go's + * `time.RFC3339` layout, `2006-01-02T15:04:05Z07:00`, and a value it cannot parse comes back as a `422` whose + * message is the raw Go parse error — `docs/HAZARDS.md` §4 captures exactly that response for `?since=notadate`. + * + * Seconds are the finest unit emitted. `Instant.toString` would append fractional seconds when it has them, which Go + * does accept, but truncating keeps the rendering stable regardless of where the caller's instant came from, and + * keeps a `since` cursor byte-identical between runs. + */ + def render(value: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(value.truncatedTo(ChronoUnit.SECONDS)) + /** The shortest string the fixed layout can be: `yyyy-MM-ddTHH:mm:ssZ`. */ private val MinimumLength: Int = 20 diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala index 10de203..f5d8d0d 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.AddTrackedTime /** Forgejo's `AddTimeOption` request model — the body of `POST /repos/{owner}/{repo}/issues/{index}/times`. @@ -24,5 +25,5 @@ private[codeberg4s] object AddTimeOptionDto: List( Some("time" -> WireNumbers.whole(command.spent.toSeconds)), command.userName.map(login => "user_name" -> JsonValue.Str(login)), - command.createdAt.map(moment => "created" -> JsonValue.Str(WireInstant.render(moment))), + command.createdAt.map(moment => "created" -> JsonValue.Str(Timestamps.render(moment))), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala index 4ae401b..ce9ef1a 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.CreateIssue /** Forgejo's `CreateIssueOption` request model — the body of `POST /repos/{owner}/{repo}/issues`. @@ -30,7 +31,7 @@ private[codeberg4s] object CreateIssueOptionDto: Option.when(command.assignees.nonEmpty)("assignees" -> WireNumbers.strings(command.assignees)), Option.when(command.labels.nonEmpty)("labels" -> WireNumbers.identifiers(command.labels.map(_.value))), command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), - command.dueDate.map(moment => "due_date" -> JsonValue.Str(WireInstant.render(moment))), + command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), command.ref.map(reference => "ref" -> JsonValue.Str(reference)), Option.when(command.closed)("closed" -> JsonValue.Bool(true)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditDeadlineOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditDeadlineOptionDto.scala index 3888caa..09e5acf 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditDeadlineOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditDeadlineOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import java.time.Instant @@ -11,8 +12,8 @@ import java.time.Instant * `spec/swagger.v1.json`; no golden capture of this request exists. * * `due_date` is the model's one property and the only one the spec marks `required` anywhere in this group's request - * models. It is always emitted, rendered by [[WireInstant]] in the RFC-3339 form Go parses — a malformed one comes - * back as a `422` carrying a raw Go parse error, per `docs/HAZARDS.md` §4. + * models. It is always emitted, rendered by [[Timestamps.render]] in the RFC-3339 form Go parses — a malformed one + * comes back as a `422` carrying a raw Go parse error, per `docs/HAZARDS.md` §4. * * '''There is no way to clear a deadline through this endpoint.''' `due_date` is required, so a caller who wants no * deadline uses [[com.worxbend.codeberg4s.issues.EditIssue.withoutDueDate]] on the issue edit instead, which sends the @@ -22,4 +23,4 @@ private[codeberg4s] object EditDeadlineOptionDto: /** Renders `dueDate` as the JSON body to `POST`. */ def render(dueDate: Instant): String = - Json.render(JsonValue.Obj("due_date" -> JsonValue.Str(WireInstant.render(dueDate)))) + Json.render(JsonValue.Obj("due_date" -> JsonValue.Str(Timestamps.render(dueDate)))) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueCommentOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueCommentOptionDto.scala index 664670c..99a8924 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueCommentOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueCommentOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.EditComment /** Forgejo's `EditIssueCommentOption` request model — the body of `PATCH /repos/{owner}/{repo}/issues/comments/{id}` @@ -25,5 +26,5 @@ private[codeberg4s] object EditIssueCommentOptionDto: private def fields(command: EditComment): List[(String, JsonValue)] = List( Some("body" -> JsonValue.Str(command.body)), - command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(WireInstant.render(moment))), + command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(Timestamps.render(moment))), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala index d8d2bfe..59fff7f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.EditIssue /** Forgejo's `EditIssueOption` request model — the body of `PATCH /repos/{owner}/{repo}/issues/{index}`. @@ -34,7 +35,7 @@ private[codeberg4s] object EditIssueOptionDto: command.assignees.map(logins => "assignees" -> WireNumbers.strings(logins)), command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), command.state.map(change => "state" -> JsonValue.Str(change.wireValue)), - command.dueDate.map(moment => "due_date" -> JsonValue.Str(WireInstant.render(moment))), + command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), Option.when(command.unsetDueDate)("unset_due_date" -> JsonValue.Bool(true)), command.ref.map(reference => "ref" -> JsonValue.Str(reference)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala index 16e9014..7c3d393 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.LabelRef import com.worxbend.codeberg4s.issues.LabelRemoval import com.worxbend.codeberg4s.issues.LabelUpdate @@ -35,14 +36,14 @@ private[codeberg4s] object IssueLabelsOptionDto: val labels = JsonValue.Arr.from(command.labels.map(reference)) val fields = List( Some("labels" -> (labels: JsonValue)), - command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(WireInstant.render(moment))), + command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(Timestamps.render(moment))), ).flatten Json.render(JsonValue.Obj.from(fields)) /** Renders `command` as the JSON body of the label clear or single removal; `{}` when it says nothing. */ def renderRemoval(command: LabelRemoval): String = - val fields = command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(WireInstant.render(moment))).toList + val fields = command.updatedAt.map(moment => "updated_at" -> JsonValue.Str(Timestamps.render(moment))).toList Json.render(JsonValue.Obj.from(fields)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala index dfcf7a8..14aedc6 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueQueries.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.PagingQuery +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.CommentQuery import com.worxbend.codeberg4s.issues.IssueQuery import com.worxbend.codeberg4s.issues.IssueSearchQuery @@ -40,8 +41,8 @@ private[codeberg4s] object IssueQueries: * elements are [[com.worxbend.codeberg4s.issues.LabelName]] and [[com.worxbend.codeberg4s.issues.MilestoneTitle]], * which reject a comma at construction. * - * `since` and `before` are rendered by [[WireInstant]] in the RFC-3339 form Go parses — a malformed one comes back - * as a `422` carrying a raw Go parse error, per `docs/HAZARDS.md` §4. + * `since` and `before` are rendered by [[Timestamps.render]] in the RFC-3339 form Go parses — a malformed one comes + * back as a `422` carrying a raw Go parse error, per `docs/HAZARDS.md` §4. */ def issues(query: IssueQuery): List[(String, String)] = List( @@ -49,8 +50,8 @@ private[codeberg4s] object IssueQueries: Option.when(query.labels.nonEmpty)("labels" -> query.labels.map(_.value).mkString(",")), query.text.map(keywords => "q" -> keywords), Option.when(query.milestones.nonEmpty)("milestones" -> query.milestones.map(_.value).mkString(",")), - query.since.map(moment => "since" -> WireInstant.render(moment)), - query.before.map(moment => "before" -> WireInstant.render(moment)), + query.since.map(moment => "since" -> Timestamps.render(moment)), + query.before.map(moment => "before" -> Timestamps.render(moment)), query.createdBy.map(login => "created_by" -> login), query.assignedBy.map(login => "assigned_by" -> login), ).flatten @@ -71,8 +72,8 @@ private[codeberg4s] object IssueQueries: * only a `true` is emitted: sending `assigned=false` and omitting it ask the same question, and a query string * carrying five explicit falsehoods is harder to read for no gain. * - * `labels` and `milestones` are comma-joined, and `since` and `before` go through [[WireInstant]], exactly as in - * [[issues]]. + * `labels` and `milestones` are comma-joined, and `since` and `before` go through [[Timestamps.render]], exactly as + * in [[issues]]. */ def search(query: IssueSearchQuery): List[(String, String)] = List( @@ -82,8 +83,8 @@ private[codeberg4s] object IssueQueries: query.text.map(keywords => "q" -> keywords), query.priorityRepoId.map(id => "priority_repo_id" -> id.toString), query.kind.map(which => "type" -> which.wireValue), - query.since.map(moment => "since" -> WireInstant.render(moment)), - query.before.map(moment => "before" -> WireInstant.render(moment)), + query.since.map(moment => "since" -> Timestamps.render(moment)), + query.before.map(moment => "before" -> Timestamps.render(moment)), Option.when(query.assigned)("assigned" -> "true"), Option.when(query.created)("created" -> "true"), Option.when(query.mentioned)("mentioned" -> "true"), @@ -101,16 +102,16 @@ private[codeberg4s] object IssueQueries: */ def comments(query: CommentQuery): List[(String, String)] = List( - query.since.map(moment => "since" -> WireInstant.render(moment)), - query.before.map(moment => "before" -> WireInstant.render(moment)), + query.since.map(moment => "since" -> Timestamps.render(moment)), + query.before.map(moment => "before" -> Timestamps.render(moment)), ).flatten /** The filters of `GET /repos/{owner}/{repo}/issues/{index}/times`, in the order the spec declares them. */ def trackedTimes(query: TrackedTimeQuery): List[(String, String)] = List( query.userName.map(login => "user" -> login), - query.since.map(moment => "since" -> WireInstant.render(moment)), - query.before.map(moment => "before" -> WireInstant.render(moment)), + query.since.map(moment => "since" -> Timestamps.render(moment)), + query.before.map(moment => "before" -> Timestamps.render(moment)), ).flatten /** The optional `name` and `updated_at` of an attachment upload. @@ -122,5 +123,5 @@ private[codeberg4s] object IssueQueries: def attachmentUpload(upload: UploadAttachment): List[(String, String)] = List( upload.storedName.map(stored => "name" -> stored), - upload.updatedAt.map(moment => "updated_at" -> WireInstant.render(moment)), + upload.updatedAt.map(moment => "updated_at" -> Timestamps.render(moment)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneOptionDto.scala index 7e5bcb3..56660ab 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/MilestoneOptionDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.CreateMilestone import com.worxbend.codeberg4s.issues.EditMilestone @@ -29,7 +30,7 @@ private[codeberg4s] object MilestoneOptionDto: val fields = List( Some("title" -> JsonValue.Str(command.title)), command.description.map(text => "description" -> JsonValue.Str(text)), - command.dueOn.map(moment => "due_on" -> JsonValue.Str(WireInstant.render(moment))), + command.dueOn.map(moment => "due_on" -> JsonValue.Str(Timestamps.render(moment))), command.state.map(transition => "state" -> JsonValue.Str(transition.wireValue)), ).flatten @@ -40,7 +41,7 @@ private[codeberg4s] object MilestoneOptionDto: val fields = List( command.title.map(text => "title" -> JsonValue.Str(text)), command.description.map(text => "description" -> JsonValue.Str(text)), - command.dueOn.map(moment => "due_on" -> JsonValue.Str(WireInstant.render(moment))), + command.dueOn.map(moment => "due_on" -> JsonValue.Str(Timestamps.render(moment))), command.state.map(transition => "state" -> JsonValue.Str(transition.wireValue)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireInstant.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireInstant.scala deleted file mode 100644 index fc87dfa..0000000 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireInstant.scala +++ /dev/null @@ -1,25 +0,0 @@ -package com.worxbend.codeberg4s.issues.wire - -import java.time.Instant -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit - -/** Renders an instant the way Forgejo parses one. - * - * The counterpart of [[com.worxbend.codeberg4s.codec.Timestamps]], which only reads. Reading is lenient and writing - * cannot be: Forgejo parses timestamps with Go's `time.RFC3339` layout, `2006-01-02T15:04:05Z07:00`, and a value it - * cannot parse comes back as a `422` whose message is the raw Go parse error — `docs/HAZARDS.md` §4 captures exactly - * that response for `?since=notadate`. - * - * Seconds are the finest unit emitted. `Instant.toString` would append fractional seconds when it has them, which Go - * does accept, but truncating keeps the rendering stable regardless of where the caller's instant came from, and keeps - * a `since` cursor byte-identical between runs. - * - * Internal to this group's wire package. It is a candidate to move into `com.worxbend.codeberg4s.codec` the moment a - * second endpoint group needs to '''send''' a timestamp; until then, promoting it would be speculative. - */ -private[codeberg4s] object WireInstant: - - /** `value` as RFC-3339 with a `Z` offset and second precision, for example `"2026-08-01T18:14:16Z"`. */ - def render(value: Instant): String = - DateTimeFormatter.ISO_INSTANT.format(value.truncatedTo(ChronoUnit.SECONDS)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala index 272cbd3..ed56dec 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationQueries.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.notifications.wire import com.worxbend.codeberg4s.codec.PagingQuery -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.notifications.NotificationQuery import com.worxbend.codeberg4s.paging.PageParams @@ -37,7 +37,7 @@ private[codeberg4s] object NotificationQueries: /** The filters of both notification listings, in the order the spec declares them. * - * `since` and `before` are rendered by [[com.worxbend.codeberg4s.issues.wire.WireInstant]] in the RFC-3339 form Go + * `since` and `before` are rendered by [[com.worxbend.codeberg4s.codec.Timestamps.render]] in the RFC-3339 form Go * parses — reused rather than copied, because a second timestamp renderer that drifted from the first would show up * as a `422` carrying a raw Go parse error, exactly as `docs/HAZARDS.md` §4 captured for `?since=notadate`. */ @@ -46,6 +46,6 @@ private[codeberg4s] object NotificationQueries: Option.when(query.includeRead)("all" -> "true").toList, query.statuses.map(status => "status-types" -> status.wireValue).toList, query.subjects.map(subject => "subject-type" -> subject.wireValue).toList, - query.since.map(moment => "since" -> WireInstant.render(moment)).toList, - query.before.map(moment => "before" -> WireInstant.render(moment)).toList, + query.since.map(moment => "since" -> Timestamps.render(moment)).toList, + query.before.map(moment => "before" -> Timestamps.render(moment)).toList, ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala index 4d1106e..f810ddd 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.wire.WireNumbers import com.worxbend.codeberg4s.pulls.CreatePullRequest @@ -21,7 +21,7 @@ import com.worxbend.codeberg4s.pulls.CreatePullRequest * `assignees` and `labels` are emitted only when non-empty, for the same reason: an explicit `[]` is a statement, and * a caller who never called [[com.worxbend.codeberg4s.pulls.CreatePullRequest.labelled]] made no statement. * - * [[com.worxbend.codeberg4s.issues.wire.WireInstant]] and [[com.worxbend.codeberg4s.issues.wire.WireNumbers]] are the + * [[com.worxbend.codeberg4s.codec.Timestamps.render]] and [[com.worxbend.codeberg4s.issues.wire.WireNumbers]] are the * issue wave's, reused rather than copied per `docs/LEDGER.md`; both are `private[codeberg4s]` and both are listed * there as candidates for promotion into `codec`. */ @@ -40,5 +40,5 @@ private[codeberg4s] object CreatePullRequestOptionDto: Option.when(command.assignees.nonEmpty)("assignees" -> WireNumbers.strings(command.assignees)), Option.when(command.labels.nonEmpty)("labels" -> WireNumbers.identifiers(command.labels.map(_.value))), command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), - command.dueDate.map(moment => "due_date" -> JsonValue.Str(WireInstant.render(moment))), + command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala index 1f4fd57..e45b4ff 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.issues.wire.WireNumbers import com.worxbend.codeberg4s.pulls.EditPullRequest @@ -41,7 +41,7 @@ private[codeberg4s] object EditPullRequestOptionDto: command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), command.state.map(change => "state" -> JsonValue.Str(change.wireValue)), command.base.map(branch => "base" -> JsonValue.Str(branch.value)), - command.dueDate.map(moment => "due_date" -> JsonValue.Str(WireInstant.render(moment))), + command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), Option.when(command.unsetDueDate)("unset_due_date" -> JsonValue.Bool(true)), command.allowMaintainerEdit.map(allowed => "allow_maintainer_edit" -> JsonValue.Bool(allowed)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FileOptionsDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FileOptionsDto.scala index 6b85299..fe7d647 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FileOptionsDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FileOptionsDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.repositories.admin.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.repositories.admin.ChangeFiles import com.worxbend.codeberg4s.repositories.admin.CommitDates import com.worxbend.codeberg4s.repositories.admin.CommitIdentity @@ -104,8 +104,8 @@ private[codeberg4s] object FileOptionsDto: */ private def dates(when: CommitDates): Option[JsonValue] = val fields = List( - when.author.map(moment => "author" -> JsonValue.Str(WireInstant.render(moment))), - when.committer.map(moment => "committer" -> JsonValue.Str(WireInstant.render(moment))), + when.author.map(moment => "author" -> JsonValue.Str(Timestamps.render(moment))), + when.committer.map(moment => "committer" -> JsonValue.Str(Timestamps.render(moment))), ).flatten Option.when(fields.nonEmpty)(JsonValue.Obj.from(fields)) diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/DiffPatchOptionsDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/DiffPatchOptionsDto.scala index c65753d..87e5721 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/DiffPatchOptionsDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/DiffPatchOptionsDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.repositories.gitdata.ApplyDiffPatch import com.worxbend.codeberg4s.repositories.gitdata.GitAuthor @@ -53,6 +53,6 @@ private[codeberg4s] object DiffPatchOptionsDto: private def dates(authored: Option[Instant], committed: Option[Instant]): Option[(String, JsonValue)] = authored.zip(committed).map: (author, committer) => "dates" -> JsonValue.Obj( - "author" -> JsonValue.Str(WireInstant.render(author)), - "committer" -> JsonValue.Str(WireInstant.render(committer)), + "author" -> JsonValue.Str(Timestamps.render(author)), + "committer" -> JsonValue.Str(Timestamps.render(committer)), ) diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala index b1feae3..8a987ba 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/social/wire/SocialQueries.scala @@ -1,7 +1,7 @@ package com.worxbend.codeberg4s.users.social.wire import com.worxbend.codeberg4s.codec.PagingQuery -import com.worxbend.codeberg4s.issues.wire.WireInstant +import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.users.social.ActivityFeedQuery import com.worxbend.codeberg4s.users.social.TrackedTimeWindow @@ -45,7 +45,7 @@ private[codeberg4s] object SocialQueries: * * The date is rendered as `yyyy-MM-dd`, which is what the spec's `format: date` means and what Go's date parsing * accepts. A [[java.time.LocalDate]] has no time zone to lose, so unlike - * [[com.worxbend.codeberg4s.issues.wire.WireInstant]] there is nothing to normalise here. + * [[com.worxbend.codeberg4s.codec.Timestamps.render]] there is nothing to normalise here. */ def activityFeeds(query: ActivityFeedQuery): List[(String, String)] = List( @@ -55,13 +55,13 @@ private[codeberg4s] object SocialQueries: /** The window of the tracked-time listing. * - * `since` and `before` are rendered by [[com.worxbend.codeberg4s.issues.wire.WireInstant]] in the RFC-3339 form Go + * `since` and `before` are rendered by [[com.worxbend.codeberg4s.codec.Timestamps.render]] in the RFC-3339 form Go * parses — a malformed one comes back as a `422` whose message is the raw Go parse error, per `docs/HAZARDS.md` §4. */ def trackedTimes(window: TrackedTimeWindow): List[(String, String)] = List( - window.since.map(moment => SinceKey -> WireInstant.render(moment)), - window.before.map(moment => BeforeKey -> WireInstant.render(moment)), + window.since.map(moment => SinceKey -> Timestamps.render(moment)), + window.before.map(moment => BeforeKey -> Timestamps.render(moment)), ).flatten /** `yyyy-MM-dd`, the spec's `format: date`. Immutable and safe to share. */ From 55d8933754915326152d17ce1aaca369e9c9cf8a Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:30:08 +0300 Subject: [PATCH 24/31] refactor(codec): promote the request-body value builders into codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues.wire.WireNumbers` built the JSON scalars and arrays a request body is made of, and said in its own note that it should move into `codec` once a second endpoint group wrote a body. Pulls already imported it out of the issues package, which is that moment arriving without anyone acting on it. It moves to `codec.WireValues`, the `domain → wire` counterpart of the `Wire` object that reads the other way. The new name drops the "numbers" claim, which was never true: alongside the identifier and whole-number builders it also renders arrays of strings. The bodies are unchanged, so an `int64` field still reaches the wire through `JsonValue.Num` and is exact past 2^53. --- .../WireNumbers.scala => codec/WireValues.scala} | 12 +++++------- .../codeberg4s/issues/wire/AddTimeOptionDto.scala | 3 ++- .../issues/wire/CreateIssueOptionDto.scala | 7 ++++--- .../issues/wire/EditIssueOptionDto.scala | 5 +++-- .../issues/wire/IssueLabelsOptionDto.scala | 3 ++- .../codeberg4s/issues/wire/IssueMetaDto.scala | 3 ++- .../pulls/wire/CreatePullRequestOptionDto.scala | 14 +++++++------- .../pulls/wire/EditPullRequestOptionDto.scala | 8 ++++---- .../pulls/wire/NewReviewCommentDto.scala | 8 ++++---- .../pulls/wire/PullReviewRequestOptionsDto.scala | 6 +++--- 10 files changed, 36 insertions(+), 33 deletions(-) rename modules/codec/src/com/worxbend/codeberg4s/{issues/wire/WireNumbers.scala => codec/WireValues.scala} (75%) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireNumbers.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/WireValues.scala similarity index 75% rename from modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireNumbers.scala rename to modules/codec/src/com/worxbend/codeberg4s/codec/WireValues.scala index b9e5e53..da6d4ef 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireNumbers.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/WireValues.scala @@ -1,17 +1,15 @@ -package com.worxbend.codeberg4s.issues.wire +package com.worxbend.codeberg4s.codec -import com.worxbend.codeberg4s.codec.JsonValue - -/** Builds the JSON scalars and arrays this group's request bodies are made of. +/** Builds the JSON scalars and arrays a request body is made of. * * [[JsonValue.Num]] puts a whole number into the document model's `Long` case, so an `int64` identifier reaches the * wire exactly — a document model that held a `Double`, as an earlier one did, represents integers exactly only up to * 2^53. Building the scalars here rather than at each call site means the conversion happens once. * - * Internal to this group's wire package, and a candidate to move into `com.worxbend.codeberg4s.codec` once a second - * endpoint group writes a request body. + * The `domain → wire` counterpart of [[Wire]], which reads the other way. Every endpoint group that sends a body uses + * it, so the spelling of an `int64` field is decided here rather than once per group. */ -private[codeberg4s] object WireNumbers: +private[codeberg4s] object WireValues: /** One identifier as a JSON number. */ def identifier(value: Long): JsonValue = diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala index f5d8d0d..ac02471 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/AddTimeOptionDto.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.issues.AddTrackedTime /** Forgejo's `AddTimeOption` request model — the body of `POST /repos/{owner}/{repo}/issues/{index}/times`. @@ -23,7 +24,7 @@ private[codeberg4s] object AddTimeOptionDto: private def fields(command: AddTrackedTime): List[(String, JsonValue)] = List( - Some("time" -> WireNumbers.whole(command.spent.toSeconds)), + Some("time" -> WireValues.whole(command.spent.toSeconds)), command.userName.map(login => "user_name" -> JsonValue.Str(login)), command.createdAt.map(moment => "created" -> JsonValue.Str(Timestamps.render(moment))), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala index ce9ef1a..1f5917d 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CreateIssueOptionDto.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.issues.CreateIssue /** Forgejo's `CreateIssueOption` request model — the body of `POST /repos/{owner}/{repo}/issues`. @@ -28,9 +29,9 @@ private[codeberg4s] object CreateIssueOptionDto: List( Some("title" -> JsonValue.Str(command.title)), command.body.map(text => "body" -> JsonValue.Str(text)), - Option.when(command.assignees.nonEmpty)("assignees" -> WireNumbers.strings(command.assignees)), - Option.when(command.labels.nonEmpty)("labels" -> WireNumbers.identifiers(command.labels.map(_.value))), - command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), + Option.when(command.assignees.nonEmpty)("assignees" -> WireValues.strings(command.assignees)), + Option.when(command.labels.nonEmpty)("labels" -> WireValues.identifiers(command.labels.map(_.value))), + command.milestone.map(id => "milestone" -> WireValues.identifier(id.value)), command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), command.ref.map(reference => "ref" -> JsonValue.Str(reference)), Option.when(command.closed)("closed" -> JsonValue.Bool(true)), diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala index 59fff7f..5fa5921 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/EditIssueOptionDto.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.issues.EditIssue /** Forgejo's `EditIssueOption` request model — the body of `PATCH /repos/{owner}/{repo}/issues/{index}`. @@ -32,8 +33,8 @@ private[codeberg4s] object EditIssueOptionDto: List( command.title.map(text => "title" -> JsonValue.Str(text)), command.body.map(text => "body" -> JsonValue.Str(text)), - command.assignees.map(logins => "assignees" -> WireNumbers.strings(logins)), - command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), + command.assignees.map(logins => "assignees" -> WireValues.strings(logins)), + command.milestone.map(id => "milestone" -> WireValues.identifier(id.value)), command.state.map(change => "state" -> JsonValue.Str(change.wireValue)), command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), Option.when(command.unsetDueDate)("unset_due_date" -> JsonValue.Bool(true)), diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala index 7c3d393..225bdf0 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueLabelsOptionDto.scala @@ -3,6 +3,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.issues.LabelRef import com.worxbend.codeberg4s.issues.LabelRemoval import com.worxbend.codeberg4s.issues.LabelUpdate @@ -49,5 +50,5 @@ private[codeberg4s] object IssueLabelsOptionDto: private def reference(label: LabelRef): JsonValue = label match - case LabelRef.ById(id) => WireNumbers.identifier(id.value) + case LabelRef.ById(id) => WireValues.identifier(id.value) case LabelRef.ByName(name) => JsonValue.Str(name.value) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueMetaDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueMetaDto.scala index d6467d0..a4bfea5 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueMetaDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueMetaDto.scala @@ -2,6 +2,7 @@ package com.worxbend.codeberg4s.issues.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.issues.IssueRef /** Forgejo's `IssueMeta` request model — the body of all six blocking and dependency calls. @@ -26,6 +27,6 @@ private[codeberg4s] object IssueMetaDto: JsonValue.Obj( "owner" -> JsonValue.Str(reference.owner.value), "repo" -> JsonValue.Str(reference.repo.value), - "index" -> WireNumbers.identifier(reference.number.value), + "index" -> WireValues.identifier(reference.number.value), ) ) diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala index f810ddd..c6ac796 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/CreatePullRequestOptionDto.scala @@ -3,7 +3,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps -import com.worxbend.codeberg4s.issues.wire.WireNumbers +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.pulls.CreatePullRequest /** Forgejo's `CreatePullRequestOption` request model — the body of `POST /repos/{owner}/{repo}/pulls`. @@ -21,9 +21,9 @@ import com.worxbend.codeberg4s.pulls.CreatePullRequest * `assignees` and `labels` are emitted only when non-empty, for the same reason: an explicit `[]` is a statement, and * a caller who never called [[com.worxbend.codeberg4s.pulls.CreatePullRequest.labelled]] made no statement. * - * [[com.worxbend.codeberg4s.codec.Timestamps.render]] and [[com.worxbend.codeberg4s.issues.wire.WireNumbers]] are the - * issue wave's, reused rather than copied per `docs/LEDGER.md`; both are `private[codeberg4s]` and both are listed - * there as candidates for promotion into `codec`. + * [[com.worxbend.codeberg4s.codec.Timestamps.render]] and [[com.worxbend.codeberg4s.codec.WireValues]] are the issue + * wave's, reused rather than copied per `docs/LEDGER.md`; both are `private[codeberg4s]` and both are listed there as + * candidates for promotion into `codec`. */ private[codeberg4s] object CreatePullRequestOptionDto: @@ -37,8 +37,8 @@ private[codeberg4s] object CreatePullRequestOptionDto: Some("head" -> JsonValue.Str(command.head.value)), Some("base" -> JsonValue.Str(command.base.value)), command.body.map(text => "body" -> JsonValue.Str(text)), - Option.when(command.assignees.nonEmpty)("assignees" -> WireNumbers.strings(command.assignees)), - Option.when(command.labels.nonEmpty)("labels" -> WireNumbers.identifiers(command.labels.map(_.value))), - command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), + Option.when(command.assignees.nonEmpty)("assignees" -> WireValues.strings(command.assignees)), + Option.when(command.labels.nonEmpty)("labels" -> WireValues.identifiers(command.labels.map(_.value))), + command.milestone.map(id => "milestone" -> WireValues.identifier(id.value)), command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala index e45b4ff..8cb3d61 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/EditPullRequestOptionDto.scala @@ -3,7 +3,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue import com.worxbend.codeberg4s.codec.Timestamps -import com.worxbend.codeberg4s.issues.wire.WireNumbers +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.pulls.EditPullRequest /** Forgejo's `EditPullRequestOption` request model — the body of `PATCH /repos/{owner}/{repo}/pulls/{index}`. @@ -36,9 +36,9 @@ private[codeberg4s] object EditPullRequestOptionDto: List( command.title.map(text => "title" -> JsonValue.Str(text)), command.body.map(text => "body" -> JsonValue.Str(text)), - command.assignees.map(logins => "assignees" -> WireNumbers.strings(logins)), - command.labels.map(ids => "labels" -> WireNumbers.identifiers(ids.map(_.value))), - command.milestone.map(id => "milestone" -> WireNumbers.identifier(id.value)), + command.assignees.map(logins => "assignees" -> WireValues.strings(logins)), + command.labels.map(ids => "labels" -> WireValues.identifiers(ids.map(_.value))), + command.milestone.map(id => "milestone" -> WireValues.identifier(id.value)), command.state.map(change => "state" -> JsonValue.Str(change.wireValue)), command.base.map(branch => "base" -> JsonValue.Str(branch.value)), command.dueDate.map(moment => "due_date" -> JsonValue.Str(Timestamps.render(moment))), diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/NewReviewCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/NewReviewCommentDto.scala index e8134ec..b4e6865 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/NewReviewCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/NewReviewCommentDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireNumbers +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.pulls.NewReviewComment /** Forgejo's `CreatePullReviewComment` request model. @@ -33,7 +33,7 @@ private[codeberg4s] object NewReviewCommentDto: List( Some("body" -> JsonValue.Str(comment.body)), Some("path" -> JsonValue.Str(comment.path)), - comment.newPosition.map(line => "new_position" -> WireNumbers.identifier(line)), - comment.oldPosition.map(line => "old_position" -> WireNumbers.identifier(line)), - comment.extraLinesCount.map(span => "extra_lines_count" -> WireNumbers.identifier(span)), + comment.newPosition.map(line => "new_position" -> WireValues.identifier(line)), + comment.oldPosition.map(line => "old_position" -> WireValues.identifier(line)), + comment.extraLinesCount.map(span => "extra_lines_count" -> WireValues.identifier(span)), ).flatten diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullReviewRequestOptionsDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullReviewRequestOptionsDto.scala index b29960a..1899b28 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullReviewRequestOptionsDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullReviewRequestOptionsDto.scala @@ -2,7 +2,7 @@ package com.worxbend.codeberg4s.pulls.wire import com.worxbend.codeberg4s.codec.Json import com.worxbend.codeberg4s.codec.JsonValue -import com.worxbend.codeberg4s.issues.wire.WireNumbers +import com.worxbend.codeberg4s.codec.WireValues import com.worxbend.codeberg4s.pulls.ReviewRequest /** Forgejo's `PullReviewRequestOptions` request model — the body of '''both''' the `POST` and the `DELETE` on @@ -25,6 +25,6 @@ private[codeberg4s] object PullReviewRequestOptionsDto: private def fields(request: ReviewRequest): List[(String, JsonValue)] = List( - Option.when(request.reviewers.nonEmpty)("reviewers" -> WireNumbers.strings(request.reviewers.map(_.value))), - Option.when(request.teams.nonEmpty)("team_reviewers" -> WireNumbers.strings(request.teams)), + Option.when(request.reviewers.nonEmpty)("reviewers" -> WireValues.strings(request.reviewers.map(_.value))), + Option.when(request.teams.nonEmpty)("team_reviewers" -> WireValues.strings(request.teams)), ).flatten From 0a2f0e5606d3fbdd5520079e94b46f9629efd927 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:33:54 +0300 Subject: [PATCH 25/31] refactor(codec): fold nested DTO conversion into Wire.nested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Almost every DTO carried its own private helper to convert an embedded model — `user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")) .map(Some.apply))` — written out once per nested field across 27 files. The shape never varied: absence stays absence, and a present DTO is converted at the field's own path so a failure reads `$.milestone.title` instead of `$.title`. Wire.nested now states that once, next to `required`, `validated` and `optional`, and the call sites read as one line in the for-comprehension with no private helper — and no domain-type import — behind them. Behaviour is unchanged: same paths, same messages, same failure order. --- .../com/worxbend/codeberg4s/codec/Wire.scala | 23 +++++++++ .../codeberg4s/issues/wire/CommentDto.scala | 6 +-- .../codeberg4s/issues/wire/IssueDto.scala | 11 +---- .../codeberg4s/issues/wire/ReactionDto.scala | 6 +-- .../issues/wire/TimelineCommentDto.scala | 48 ++++--------------- .../issues/wire/TrackedTimeDto.scala | 6 +-- .../wire/NotificationThreadDto.scala | 12 +---- .../organizations/wire/TeamDto.scala | 6 +-- .../pulls/wire/PullRequestBranchDto.scala | 6 +-- .../pulls/wire/PullRequestDto.scala | 25 ++-------- .../pulls/wire/ReviewCommentDto.scala | 8 +--- .../codeberg4s/pulls/wire/ReviewDto.scala | 6 +-- .../access/wire/DeployKeyDto.scala | 6 +-- .../actions/wire/ActionRunDto.scala | 6 +-- .../repositories/admin/wire/ActivityDto.scala | 18 ++----- .../admin/wire/FilesResponseDto.scala | 7 +-- .../gitdata/wire/AnnotatedTagDto.scala | 6 +-- .../gitdata/wire/CombinedStatusDto.scala | 6 +-- .../gitdata/wire/CommitStatusDto.scala | 6 +-- .../gitdata/wire/FileCommitDto.scala | 6 +-- .../gitdata/wire/FileResponseDto.scala | 13 ++--- .../repositories/gitdata/wire/NoteDto.scala | 7 +-- .../gitdata/wire/ReferenceDto.scala | 6 +-- .../repositories/hooks/wire/WikiPageDto.scala | 4 +- .../repositories/wire/CommitDto.scala | 14 ++---- .../repositories/wire/ReleaseDto.scala | 6 +-- .../repositories/wire/RepoCommitDto.scala | 7 +-- .../repositories/wire/RepositoryDto.scala | 5 +- .../codeberg4s/repositories/wire/TagDto.scala | 6 +-- .../codeberg4s/users/wire/PublicKeyDto.scala | 6 +-- 30 files changed, 77 insertions(+), 221 deletions(-) diff --git a/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala b/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala index 1e1146d..ef22a45 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/codec/Wire.scala @@ -68,3 +68,26 @@ object Wire: construct(present) match case Right(built) => Right(Some(built)) case Left(error) => Left(DecodeFailure(at.field(field), error.message)) + + /** Converts an optional nested DTO, rooting its own failures at the field it was read from. + * + * Rule 2 makes a nested model optional too, so `owner`, `milestone` and `repository` all arrive as an `Option[…Dto]` + * whose conversion is itself fallible. Absence stays absence; a DTO that is present is converted at + * `at.field(field)` so a failure inside it reads `$.milestone.title` rather than `$.title`. + * + * `convert` is the nested DTO's own `toDomainAt`, passed as `_.toDomainAt(_)` — taking it as a function rather than + * demanding a common trait keeps the DTOs plain case classes. + * + * @param at + * the path of the '''enclosing''' model being converted + * @param field + * the '''wire''' (snake_case) field name the nested model was read from + * @return + * `None` when the field was absent, otherwise the converted model or the nested failure + */ + def nested[A, B](at: JsonPath, field: String, dto: Option[A])( + convert: (A, JsonPath) => Either[DecodeFailure, B] + ): Either[DecodeFailure, Option[B]] = + dto match + case None => Right(None) + case Some(value) => convert(value, at.field(field)).map(Some.apply) diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala index e6aea70..b929b2c 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/CommentDto.scala @@ -9,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.Comment import com.worxbend.codeberg4s.issues.CommentId -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `Comment` model, field for field. @@ -49,7 +48,7 @@ final case class CommentDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, Comment] = for identifier <- Wire.validated(at, "id", id)(CommentId.from) - writer <- authorAt(at) + writer <- Wire.nested(at, "user", user)(_.toDomainAt(_)) yield Comment( id = identifier, body = body, @@ -66,9 +65,6 @@ final case class CommentDto( def toDomain: Either[DecodeFailure, Comment] = toDomainAt(JsonPath.Root) - private def authorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) - object CommentDto: /** Reads a `Comment` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala index b1ef417..0b5d3fb 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/IssueDto.scala @@ -10,7 +10,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.IssueNumber import com.worxbend.codeberg4s.issues.LifecycleState -import com.worxbend.codeberg4s.issues.Milestone import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto @@ -90,10 +89,10 @@ final case class IssueDto( lifecycle <- Wire.validated(at, "state", state)(value => LifecycleState.from(value, Timestamps.parseOptional(closedAt)) ) - author <- authorAt(at) + author <- Wire.nested(at, "user", user)(_.toDomainAt(_)) assigned <- assigneesAt(at) attached <- LabelDto.toDomainAll(at.field("labels"), labels) - target <- milestoneAt(at) + target <- Wire.nested(at, "milestone", milestone)(_.toDomainAt(_)) yield Issue( id = identifier, number = index, @@ -121,15 +120,9 @@ final case class IssueDto( def toDomain: Either[DecodeFailure, Issue] = toDomainAt(JsonPath.Root) - private def authorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) - private def assigneesAt(at: JsonPath): Either[DecodeFailure, Vector[User]] = ArrayElements.convert(at.field("assignees"), assignees)((dto, path) => dto.toDomainAt(path)) - private def milestoneAt(at: JsonPath): Either[DecodeFailure, Option[Milestone]] = - milestone.fold(Right(None))(dto => dto.toDomainAt(at.field("milestone")).map(Some.apply)) - object IssueDto: /** The key whose mere presence marks an issue as a pull request; see the class note. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala index e20da5b..edda8e5 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/ReactionDto.scala @@ -9,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.issues.Reaction import com.worxbend.codeberg4s.issues.ReactionContent -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `Reaction` model, field for field. @@ -33,16 +32,13 @@ final case class ReactionDto(content: Option[String], user: Option[UserDto], cre def toDomainAt(at: JsonPath): Either[DecodeFailure, Reaction] = for emoji <- Wire.validated(at, "content", content)(ReactionContent.from) - reactor <- reactorAt(at) + reactor <- Wire.nested(at, "user", user)(_.toDomainAt(_)) yield Reaction(content = emoji, user = reactor, createdAt = Timestamps.parseOptional(createdAt)) /** [[toDomainAt]] for a payload that is the whole response body. */ def toDomain: Either[DecodeFailure, Reaction] = toDomainAt(JsonPath.Root) - private def reactorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) - object ReactionDto: /** Reads a `Reaction` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala index d81a9ef..58ca1e5 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TimelineCommentDto.scala @@ -7,14 +7,8 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.Comment import com.worxbend.codeberg4s.issues.CommentId -import com.worxbend.codeberg4s.issues.Issue -import com.worxbend.codeberg4s.issues.Label -import com.worxbend.codeberg4s.issues.Milestone import com.worxbend.codeberg4s.issues.TimelineEvent -import com.worxbend.codeberg4s.issues.TrackedTime -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `TimelineComment` model — one element of `GET /repos/{owner}/{repo}/issues/{index}/timeline`. @@ -81,16 +75,16 @@ final case class TimelineCommentDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, TimelineEvent] = for identifier <- Wire.validated(at, "id", id)(CommentId.from) - actor <- userAt(at, "user", user) - assigned <- userAt(at, "assignee", assignee) - resolver <- userAt(at, "resolve_doer", resolveDoer) - tag <- labelAt(at) - target <- milestoneAt(at, "milestone", milestone) - previous <- milestoneAt(at, "old_milestone", oldMilestone) - referring <- issueAt(at, "ref_issue", refIssue) - dependent <- issueAt(at, "dependent_issue", dependentIssue) - quoted <- commentAt(at) - logged <- trackedTimeAt(at) + actor <- Wire.nested(at, "user", user)(_.toDomainAt(_)) + assigned <- Wire.nested(at, "assignee", assignee)(_.toDomainAt(_)) + resolver <- Wire.nested(at, "resolve_doer", resolveDoer)(_.toDomainAt(_)) + tag <- Wire.nested(at, "label", label)(_.toDomainAt(_)) + target <- Wire.nested(at, "milestone", milestone)(_.toDomainAt(_)) + previous <- Wire.nested(at, "old_milestone", oldMilestone)(_.toDomainAt(_)) + referring <- Wire.nested(at, "ref_issue", refIssue)(_.toDomainAt(_)) + dependent <- Wire.nested(at, "dependent_issue", dependentIssue)(_.toDomainAt(_)) + quoted <- Wire.nested(at, "ref_comment", refComment)(_.toDomainAt(_)) + logged <- Wire.nested(at, "tracked_time", trackedTime)(_.toDomainAt(_)) yield TimelineEvent( id = identifier, eventType = commentType, @@ -126,28 +120,6 @@ final case class TimelineCommentDto( def toDomain: Either[DecodeFailure, TimelineEvent] = toDomainAt(JsonPath.Root) - private def userAt(at: JsonPath, field: String, dto: Option[UserDto]): Either[DecodeFailure, Option[User]] = - dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) - - private def labelAt(at: JsonPath): Either[DecodeFailure, Option[Label]] = - label.fold(Right(None))(dto => dto.toDomainAt(at.field("label")).map(Some.apply)) - - private def milestoneAt( - at: JsonPath, - field: String, - dto: Option[MilestoneDto], - ): Either[DecodeFailure, Option[Milestone]] = - dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) - - private def issueAt(at: JsonPath, field: String, dto: Option[IssueDto]): Either[DecodeFailure, Option[Issue]] = - dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) - - private def commentAt(at: JsonPath): Either[DecodeFailure, Option[Comment]] = - refComment.fold(Right(None))(dto => dto.toDomainAt(at.field("ref_comment")).map(Some.apply)) - - private def trackedTimeAt(at: JsonPath): Either[DecodeFailure, Option[TrackedTime]] = - trackedTime.fold(Right(None))(dto => dto.toDomainAt(at.field("tracked_time")).map(Some.apply)) - object TimelineCommentDto: /** Reads a `TimelineComment` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala index 20b91d9..2321c72 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/issues/wire/TrackedTimeDto.scala @@ -7,7 +7,6 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.Issue import com.worxbend.codeberg4s.issues.TrackedTime import com.worxbend.codeberg4s.issues.TrackedTimeId @@ -54,7 +53,7 @@ final case class TrackedTimeDto( for identifier <- Wire.validated(at, "id", id)(TrackedTimeId.from) seconds <- Wire.required(at, "time", time) - target <- issueAt(at) + target <- Wire.nested(at, "issue", issue)(_.toDomainAt(_)) yield TrackedTime( id = identifier, issue = target, @@ -67,9 +66,6 @@ final case class TrackedTimeDto( def toDomain: Either[DecodeFailure, TrackedTime] = toDomainAt(JsonPath.Root) - private def issueAt(at: JsonPath): Either[DecodeFailure, Option[Issue]] = - issue.fold(Right(None))(dto => dto.toDomainAt(at.field("issue")).map(Some.apply)) - object TrackedTimeDto: /** Reads a `TrackedTime` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala index 296b1f8..5497128 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/notifications/wire/NotificationThreadDto.scala @@ -7,10 +7,8 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.notifications.NotificationSubject import com.worxbend.codeberg4s.notifications.NotificationThread import com.worxbend.codeberg4s.notifications.NotificationThreadId -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.wire.RepositoryDto /** Forgejo's `NotificationThread` model, field for field. @@ -61,8 +59,8 @@ final case class NotificationThreadDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, NotificationThread] = for identifier <- Wire.validated(at, "id", id)(NotificationThreadId.from) - about <- subjectAt(at) - repo <- repositoryAt(at) + about <- Wire.nested(at, "subject", subject)(_.toDomainAt(_)) + repo <- Wire.nested(at, "repository", repository)(_.toDomainAt(_)) yield NotificationThread( id = identifier, subject = about, @@ -77,12 +75,6 @@ final case class NotificationThreadDto( def toDomain: Either[DecodeFailure, NotificationThread] = toDomainAt(JsonPath.Root) - private def subjectAt(at: JsonPath): Either[DecodeFailure, Option[NotificationSubject]] = - subject.fold(Right(None))(dto => dto.toDomainAt(at.field("subject")).map(Some.apply)) - - private def repositoryAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - repository.fold(Right(None))(dto => dto.toDomainAt(at.field("repository")).map(Some.apply)) - object NotificationThreadDto: /** Reads a `NotificationThread` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala index c70d1e4..e7e5daf 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/organizations/wire/TeamDto.scala @@ -6,7 +6,6 @@ import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.organizations.Organization import com.worxbend.codeberg4s.organizations.Team import com.worxbend.codeberg4s.organizations.TeamId import com.worxbend.codeberg4s.organizations.TeamPermission @@ -55,7 +54,7 @@ final case class TeamDto( for identifier <- Wire.validated(at, "id", id)(TeamId.from) label <- Wire.required(at, "name", name) - owner <- organizationAt(at) + owner <- Wire.nested(at, "organization", organization)(_.toDomainAt(_)) yield Team( id = identifier, name = label, @@ -72,9 +71,6 @@ final case class TeamDto( def toDomain: Either[DecodeFailure, Team] = toDomainAt(JsonPath.Root) - private def organizationAt(at: JsonPath): Either[DecodeFailure, Option[Organization]] = - organization.fold(Right(None))(dto => dto.toDomainAt(at.field("organization")).map(Some.apply)) - object TeamDto: /** The key holding the per-unit access levels; see the class note on why it is read by hand. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala index a6d2e61..7eb6fc1 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestBranchDto.scala @@ -8,7 +8,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.pulls.PullRequestBranch import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.wire.RepositoryDto /** Forgejo's `PRBranchInfo` model, field for field — the `base` and `head` objects of a pull request. @@ -56,7 +55,7 @@ final case class PullRequestBranchDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, PullRequestBranch] = for tip <- Wire.optional(at, "sha", sha)(CommitSha.from) - repository <- repositoryAt(at) + repository <- Wire.nested(at, "repo", repo)(_.toDomainAt(_)) yield PullRequestBranch( label = label, ref = ref.flatMap(value => BranchName.from(value).toOption), @@ -69,9 +68,6 @@ final case class PullRequestBranchDto( def toDomain: Either[DecodeFailure, PullRequestBranch] = toDomainAt(JsonPath.Root) - private def repositoryAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - repo.fold(Right(None))(dto => dto.toDomainAt(at.field("repo")).map(Some.apply)) - object PullRequestBranchDto: /** Reads a `PRBranchInfo` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala index 57bb82e..6b2aff6 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/PullRequestDto.scala @@ -7,11 +7,9 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.Milestone import com.worxbend.codeberg4s.issues.wire.LabelDto import com.worxbend.codeberg4s.issues.wire.MilestoneDto import com.worxbend.codeberg4s.pulls.PullRequest -import com.worxbend.codeberg4s.pulls.PullRequestBranch import com.worxbend.codeberg4s.pulls.PullRequestNumber import com.worxbend.codeberg4s.pulls.PullRequestState import com.worxbend.codeberg4s.repositories.CommitSha @@ -103,8 +101,8 @@ final case class PullRequestDto( identifier <- Wire.required(at, "id", id) index <- Wire.validated(at, "number", number)(PullRequestNumber.from) headline <- Wire.required(at, "title", title) - author <- userAt(at, "user", user) - merger <- userAt(at, "merged_by", mergedBy) + author <- Wire.nested(at, "user", user)(_.toDomainAt(_)) + merger <- Wire.nested(at, "merged_by", mergedBy)(_.toDomainAt(_)) mergeSha <- Wire.optional(at, "merge_commit_sha", mergeCommitSha)(CommitSha.from) ancestor <- Wire.optional(at, "merge_base", mergeBase)(CommitSha.from) lifecycle <- Wire.validated(at, "state", state)(value => @@ -120,9 +118,9 @@ final case class PullRequestDto( assigned <- usersAt(at, "assignees", assignees) reviewers <- usersAt(at, "requested_reviewers", requestedReviewers) attached <- LabelDto.toDomainAll(at.field("labels"), labels) - target <- milestoneAt(at) - into <- branchAt(at, "base", base) - from <- branchAt(at, "head", head) + target <- Wire.nested(at, "milestone", milestone)(_.toDomainAt(_)) + into <- Wire.nested(at, "base", base)(_.toDomainAt(_)) + from <- Wire.nested(at, "head", head)(_.toDomainAt(_)) yield PullRequest( id = identifier, number = index, @@ -159,22 +157,9 @@ final case class PullRequestDto( def toDomain: Either[DecodeFailure, PullRequest] = toDomainAt(JsonPath.Root) - private def userAt(at: JsonPath, field: String, dto: Option[UserDto]): Either[DecodeFailure, Option[User]] = - dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) - private def usersAt(at: JsonPath, field: String, dtos: Vector[UserDto]): Either[DecodeFailure, Vector[User]] = ArrayElements.convert(at.field(field), dtos)((dto, path) => dto.toDomainAt(path)) - private def milestoneAt(at: JsonPath): Either[DecodeFailure, Option[Milestone]] = - milestone.fold(Right(None))(dto => dto.toDomainAt(at.field("milestone")).map(Some.apply)) - - private def branchAt( - at: JsonPath, - field: String, - dto: Option[PullRequestBranchDto], - ): Either[DecodeFailure, Option[PullRequestBranch]] = - dto.fold(Right(None))(value => value.toDomainAt(at.field(field)).map(Some.apply)) - object PullRequestDto: /** Reads a `PullRequest` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala index 2928326..fbc8183 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewCommentDto.scala @@ -11,7 +11,6 @@ import com.worxbend.codeberg4s.pulls.ReviewComment import com.worxbend.codeberg4s.pulls.ReviewCommentId import com.worxbend.codeberg4s.pulls.ReviewId import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `PullReviewComment` model, field for field. @@ -67,8 +66,8 @@ final case class ReviewCommentDto( review <- Wire.optional(at, "pull_request_review_id", pullRequestReviewId.filter(_ > 0L))(ReviewId.from) pinned <- Wire.optional(at, "commit_id", commitId)(CommitSha.from) original <- Wire.optional(at, "original_commit_id", originalCommitId)(CommitSha.from) - author <- userAt(at, "user", user) - resolvedBy <- userAt(at, "resolver", resolver) + author <- Wire.nested(at, "user", user)(_.toDomainAt(_)) + resolvedBy <- Wire.nested(at, "resolver", resolver)(_.toDomainAt(_)) yield ReviewComment( id = identifier, reviewId = review, @@ -92,9 +91,6 @@ final case class ReviewCommentDto( def toDomain: Either[DecodeFailure, ReviewComment] = toDomainAt(JsonPath.Root) - private def userAt(at: JsonPath, field: String, dto: Option[UserDto]): Either[DecodeFailure, Option[User]] = - dto.fold(Right(None))(present => present.toDomainAt(at.field(field)).map(Some.apply)) - object ReviewCommentDto: /** Reads a `PullReviewComment` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala index 187c730..f4e3dce 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/pulls/wire/ReviewDto.scala @@ -11,7 +11,6 @@ import com.worxbend.codeberg4s.pulls.Review import com.worxbend.codeberg4s.pulls.ReviewId import com.worxbend.codeberg4s.pulls.ReviewState import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `PullReview` model, field for field. @@ -61,7 +60,7 @@ final case class ReviewDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, Review] = for identifier <- Wire.validated(at, "id", id)(ReviewId.from) - reviewer <- authorAt(at) + reviewer <- Wire.nested(at, "user", user)(_.toDomainAt(_)) pinned <- Wire.optional(at, "commit_id", commitId)(CommitSha.from) yield Review( id = identifier, @@ -83,9 +82,6 @@ final case class ReviewDto( def toDomain: Either[DecodeFailure, Review] = toDomainAt(JsonPath.Root) - private def authorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) - object ReviewDto: /** Reads a `PullReview` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala index 446fe8b..65251c0 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/DeployKeyDto.scala @@ -7,7 +7,6 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.access.DeployKey import com.worxbend.codeberg4s.repositories.access.DeployKeyId import com.worxbend.codeberg4s.repositories.wire.RepositoryDto @@ -85,7 +84,7 @@ final case class DeployKeyDto( for identifier <- Wire.validated(at, DeployKeyWire.Id, id)(DeployKeyId.from) material <- Wire.required(at, DeployKeyWire.Key, key) - repo <- repositoryAt(at) + repo <- Wire.nested(at, DeployKeyWire.Repository, repository)(_.toDomainAt(_)) yield DeployKey( id = identifier, key = material, @@ -102,9 +101,6 @@ final case class DeployKeyDto( def toDomain: Either[DecodeFailure, DeployKey] = toDomainAt(JsonPath.Root) - private def repositoryAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - repository.fold(Right(None))(dto => dto.toDomainAt(at.field(DeployKeyWire.Repository)).map(Some.apply)) - object DeployKeyDto: /** Reads a `DeployKey` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala index cefefd9..a181d82 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/actions/wire/ActionRunDto.scala @@ -9,7 +9,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.actions.ActionRun import com.worxbend.codeberg4s.repositories.actions.RunId -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto import scala.concurrent.duration.FiniteDuration @@ -78,7 +77,7 @@ final case class ActionRunDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, ActionRun] = for identifier <- Wire.validated(at, "id", id)(RunId.from) - author <- triggerUserAt(at) + author <- Wire.nested(at, "trigger_user", triggerUser)(_.toDomainAt(_)) yield ActionRun( id = identifier, indexInRepo = indexInRepo, @@ -108,9 +107,6 @@ final case class ActionRunDto( def toDomain: Either[DecodeFailure, ActionRun] = toDomainAt(JsonPath.Root) - private def triggerUserAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - triggerUser.fold(Right(None))(dto => dto.toDomainAt(at.field("trigger_user")).map(Some.apply)) - object ActionRunDto: /** The wire key of the cron entry that started a scheduled run — capitalised, unlike every other key here. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala index de654a5..3e0fe47 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/ActivityDto.scala @@ -7,14 +7,11 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.issues.Comment import com.worxbend.codeberg4s.issues.wire.CommentDto -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.admin.ActivityId import com.worxbend.codeberg4s.repositories.admin.ActivityOperation import com.worxbend.codeberg4s.repositories.admin.RepositoryActivity import com.worxbend.codeberg4s.repositories.wire.RepositoryDto -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `Activity` model, field for field. @@ -86,9 +83,9 @@ final case class ActivityDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, RepositoryActivity] = for identifier <- Wire.validated(at, "id", id)(ActivityId.from) - actor <- actorAt(at) - repository <- repositoryAt(at) - remark <- commentAt(at) + actor <- Wire.nested(at, "act_user", actUser)(_.toDomainAt(_)) + repository <- Wire.nested(at, "repo", repo)(_.toDomainAt(_)) + remark <- Wire.nested(at, "comment", comment)(_.toDomainAt(_)) yield RepositoryActivity( id = identifier, actor = actor, @@ -105,15 +102,6 @@ final case class ActivityDto( def toDomain: Either[DecodeFailure, RepositoryActivity] = toDomainAt(JsonPath.Root) - private def actorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - actUser.fold(Right(None))(dto => dto.toDomainAt(at.field("act_user")).map(Some.apply)) - - private def repositoryAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - repo.fold(Right(None))(dto => dto.toDomainAt(at.field("repo")).map(Some.apply)) - - private def commentAt(at: JsonPath): Either[DecodeFailure, Option[Comment]] = - comment.fold(Right(None))(dto => dto.toDomainAt(at.field("comment")).map(Some.apply)) - object ActivityDto: /** Reads an `Activity` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala index fe31943..8dc2389 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/FilesResponseDto.scala @@ -4,9 +4,9 @@ import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.codec.ArrayElements import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields +import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.admin.FileChangeSet -import com.worxbend.codeberg4s.repositories.gitdata.FileCommit import com.worxbend.codeberg4s.repositories.gitdata.wire.FileCommitDto import com.worxbend.codeberg4s.repositories.wire.ContentEntryDto import com.worxbend.codeberg4s.repositories.wire.VerificationDto @@ -41,7 +41,7 @@ final case class FilesResponseDto( */ def toDomainAt(at: JsonPath): Either[DecodeFailure, FileChangeSet] = for - written <- commitAt(at) + written <- Wire.nested(at, "commit", commit)(_.toDomainAt(_)) entries <- ArrayElements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) yield FileChangeSet(commit = written, files = entries, verification = verification.map(_.toDomain)) @@ -49,9 +49,6 @@ final case class FilesResponseDto( def toDomain: Either[DecodeFailure, FileChangeSet] = toDomainAt(JsonPath.Root) - private def commitAt(at: JsonPath): Either[DecodeFailure, Option[FileCommit]] = - commit.fold(Right(None))(dto => dto.toDomainAt(at.field("commit")).map(Some.apply)) - object FilesResponseDto: /** Reads a `FilesResponse` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/AnnotatedTagDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/AnnotatedTagDto.scala index 44f3b01..7ff3171 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/AnnotatedTagDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/AnnotatedTagDto.scala @@ -8,7 +8,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.TagName import com.worxbend.codeberg4s.repositories.gitdata.AnnotatedTag -import com.worxbend.codeberg4s.repositories.gitdata.GitObjectRef import com.worxbend.codeberg4s.repositories.wire.ArchiveDownloadCountDto import com.worxbend.codeberg4s.repositories.wire.GitIdentityDto import com.worxbend.codeberg4s.repositories.wire.VerificationDto @@ -58,7 +57,7 @@ final case class AnnotatedTagDto( for name <- Wire.validated(at, "tag", tag)(TagName.from) objectId <- Wire.validated(at, "sha", sha)(CommitSha.from) - target <- objectAt(at) + target <- Wire.nested(at, "object", obj)(_.toDomainAt(_)) yield AnnotatedTag( name = name, sha = objectId, @@ -74,9 +73,6 @@ final case class AnnotatedTagDto( def toDomain: Either[DecodeFailure, AnnotatedTag] = toDomainAt(JsonPath.Root) - private def objectAt(at: JsonPath): Either[DecodeFailure, Option[GitObjectRef]] = - obj.fold(Right(None))(dto => dto.toDomainAt(at.field("object")).map(Some.apply)) - object AnnotatedTagDto: /** Reads an `AnnotatedTag` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala index 9ea8e1f..7c91265 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CombinedStatusDto.scala @@ -7,7 +7,6 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.gitdata.CombinedCommitStatus import com.worxbend.codeberg4s.repositories.gitdata.CommitStatusState import com.worxbend.codeberg4s.repositories.wire.RepositoryDto @@ -53,7 +52,7 @@ final case class CombinedStatusDto( for commit <- Wire.validated(at, "sha", sha)(CommitSha.from) reported <- ArrayElements.convert(at.field("statuses"), statuses)((dto, path) => dto.toDomainAt(path)) - repo <- repositoryAt(at) + repo <- Wire.nested(at, "repository", repository)(_.toDomainAt(_)) yield CombinedCommitStatus( sha = commit, state = state.flatMap(CommitStatusState.parse), @@ -68,9 +67,6 @@ final case class CombinedStatusDto( def toDomain: Either[DecodeFailure, CombinedCommitStatus] = toDomainAt(JsonPath.Root) - private def repositoryAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - repository.fold(Right(None))(dto => dto.toDomainAt(at.field("repository")).map(Some.apply)) - object CombinedStatusDto: /** Reads a `CombinedStatus` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CommitStatusDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CommitStatusDto.scala index b207c17..a9991b3 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CommitStatusDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/CommitStatusDto.scala @@ -8,7 +8,6 @@ import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.gitdata.CommitStatus import com.worxbend.codeberg4s.repositories.gitdata.CommitStatusState -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `CommitStatus` model, field for field. @@ -58,7 +57,7 @@ final case class CommitStatusDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, CommitStatus] = for identifier <- Wire.required(at, "id", id) - author <- creatorAt(at) + author <- Wire.nested(at, "creator", creator)(_.toDomainAt(_)) yield CommitStatus( id = identifier, state = status.flatMap(CommitStatusState.parse), @@ -75,9 +74,6 @@ final case class CommitStatusDto( def toDomain: Either[DecodeFailure, CommitStatus] = toDomainAt(JsonPath.Root) - private def creatorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - creator.fold(Right(None))(dto => dto.toDomainAt(at.field("creator")).map(Some.apply)) - object CommitStatusDto: /** Reads a `CommitStatus` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala index 5093d81..63a2423 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileCommitDto.scala @@ -7,7 +7,6 @@ import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.CommitRef import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.gitdata.FileCommit import com.worxbend.codeberg4s.repositories.wire.CommitMetaDto @@ -58,7 +57,7 @@ final case class FileCommitDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, FileCommit] = for identifier <- Wire.validated(at, "sha", sha)(CommitSha.from) - root <- treeAt(at) + root <- Wire.nested(at, "tree", tree)(_.toDomainAt(_)) ancestors <- ArrayElements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) yield FileCommit( sha = identifier, @@ -72,9 +71,6 @@ final case class FileCommitDto( htmlUrl = htmlUrl, ) - private def treeAt(at: JsonPath): Either[DecodeFailure, Option[CommitRef]] = - tree.fold(Right(None))(dto => dto.toDomainAt(at.field("tree")).map(Some.apply)) - object FileCommitDto: /** Reads a `FileCommitResponse` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileResponseDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileResponseDto.scala index a498dfa..f4e1eaf 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileResponseDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/FileResponseDto.scala @@ -3,10 +3,9 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields +import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.ContentEntry import com.worxbend.codeberg4s.repositories.gitdata.FileChange -import com.worxbend.codeberg4s.repositories.gitdata.FileCommit import com.worxbend.codeberg4s.repositories.wire.ContentEntryDto import com.worxbend.codeberg4s.repositories.wire.VerificationDto @@ -37,20 +36,14 @@ final case class FileResponseDto( */ def toDomainAt(at: JsonPath): Either[DecodeFailure, FileChange] = for - written <- commitAt(at) - entry <- contentAt(at) + written <- Wire.nested(at, "commit", commit)(_.toDomainAt(_)) + entry <- Wire.nested(at, "content", content)(_.toDomainAt(_)) yield FileChange(commit = written, content = entry, verification = verification.map(_.toDomain)) /** [[toDomainAt]] for a payload that is the whole response body. */ def toDomain: Either[DecodeFailure, FileChange] = toDomainAt(JsonPath.Root) - private def commitAt(at: JsonPath): Either[DecodeFailure, Option[FileCommit]] = - commit.fold(Right(None))(dto => dto.toDomainAt(at.field("commit")).map(Some.apply)) - - private def contentAt(at: JsonPath): Either[DecodeFailure, Option[ContentEntry]] = - content.fold(Right(None))(dto => dto.toDomainAt(at.field("content")).map(Some.apply)) - object FileResponseDto: /** Reads a `FileResponse` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/NoteDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/NoteDto.scala index b1be5fc..3285227 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/NoteDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/NoteDto.scala @@ -3,8 +3,8 @@ package com.worxbend.codeberg4s.repositories.gitdata.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields +import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.Commit import com.worxbend.codeberg4s.repositories.gitdata.GitNote import com.worxbend.codeberg4s.repositories.wire.CommitDto @@ -27,15 +27,12 @@ final case class NoteDto(message: Option[String], commit: Option[CommitDto]): * `commit`'s own path and does fail the conversion, because a commit that cannot be identified is not a commit. */ def toDomainAt(at: JsonPath): Either[DecodeFailure, GitNote] = - commitAt(at).map(target => GitNote(message = message, commit = target)) + Wire.nested(at, "commit", commit)(_.toDomainAt(_)).map(target => GitNote(message = message, commit = target)) /** [[toDomainAt]] for a payload that is the whole response body. */ def toDomain: Either[DecodeFailure, GitNote] = toDomainAt(JsonPath.Root) - private def commitAt(at: JsonPath): Either[DecodeFailure, Option[Commit]] = - commit.fold(Right(None))(dto => dto.toDomainAt(at.field("commit")).map(Some.apply)) - object NoteDto: /** Reads a `Note` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/ReferenceDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/ReferenceDto.scala index 6cd1209..f0bbe57 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/ReferenceDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/ReferenceDto.scala @@ -5,7 +5,6 @@ import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.gitdata.GitObjectRef import com.worxbend.codeberg4s.repositories.gitdata.GitReference import com.worxbend.codeberg4s.repositories.gitdata.RefName @@ -32,16 +31,13 @@ final case class ReferenceDto(ref: Option[String], url: Option[String], obj: Opt def toDomainAt(at: JsonPath): Either[DecodeFailure, GitReference] = for name <- Wire.validated(at, "ref", ref)(RefName.from) - target <- objectAt(at) + target <- Wire.nested(at, "object", obj)(_.toDomainAt(_)) yield GitReference(name = name, url = url, target = target) /** [[toDomainAt]] for a payload that is the whole response body. */ def toDomain: Either[DecodeFailure, GitReference] = toDomainAt(JsonPath.Root) - private def objectAt(at: JsonPath): Either[DecodeFailure, Option[GitObjectRef]] = - obj.fold(Right(None))(dto => dto.toDomainAt(at.field("object")).map(Some.apply)) - object ReferenceDto: /** Reads a `Reference` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala index 41289e4..b215522 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/WikiPageDto.scala @@ -155,9 +155,7 @@ object WikiPageDto: at: JsonPath, commit: Option[WikiCommitDto], ): Either[DecodeFailure, Option[WikiCommit]] = - commit match - case None => Right(None) - case Some(dto) => dto.toDomainAt(at.field(LastCommitKey)).map(Some.apply) + Wire.nested(at, LastCommitKey, commit)(_.toDomainAt(_)) /** Forgejo's `WikiPageMetaData` model — one entry of the wiki page listing. * diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala index 1bb0e6e..e48c9bb 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/CommitDto.scala @@ -8,9 +8,7 @@ import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.Commit -import com.worxbend.codeberg4s.repositories.CommitDetails import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `Commit` model, field for field. @@ -63,9 +61,9 @@ final case class CommitDto( def toDomainAt(at: JsonPath): Either[DecodeFailure, Commit] = for identifier <- Wire.validated(at, "sha", sha)(CommitSha.from) - details <- detailsAt(at) - authorUser <- userAt(at, "author", author) - committerUser <- userAt(at, "committer", committer) + details <- Wire.nested(at, "commit", commit)(_.toDomainAt(_)) + authorUser <- Wire.nested(at, "author", author)(_.toDomainAt(_)) + committerUser <- Wire.nested(at, "committer", committer)(_.toDomainAt(_)) parentRefs <- ArrayElements.convert(at.field("parents"), parents)((dto, path) => dto.toDomainAt(path)) changed <- ArrayElements.convert(at.field("files"), files)((dto, path) => dto.toDomainAt(path)) yield Commit( @@ -85,12 +83,6 @@ final case class CommitDto( def toDomain: Either[DecodeFailure, Commit] = toDomainAt(JsonPath.Root) - private def detailsAt(at: JsonPath): Either[DecodeFailure, Option[CommitDetails]] = - commit.fold(Right(None))(dto => dto.toDomainAt(at.field("commit")).map(Some.apply)) - - private def userAt(at: JsonPath, field: String, dto: Option[UserDto]): Either[DecodeFailure, Option[User]] = - dto.fold(Right(None))(user => user.toDomainAt(at.field(field)).map(Some.apply)) - object CommitDto: /** Reads a `Commit` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala index f6efc99..622101b 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/ReleaseDto.scala @@ -10,7 +10,6 @@ import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.Release import com.worxbend.codeberg4s.repositories.ReleaseId import com.worxbend.codeberg4s.repositories.TagName -import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.wire.UserDto /** Forgejo's `Release` model, field for field. @@ -86,7 +85,7 @@ final case class ReleaseDto( for identifier <- Wire.validated(at, "id", id)(ReleaseId.from) tag <- Wire.validated(at, "tag_name", tagName)(TagName.from) - publisher <- authorAt(at) + publisher <- Wire.nested(at, "author", author)(_.toDomainAt(_)) attached <- ArrayElements.convert(at.field("assets"), assets)((dto, path) => dto.toDomainAt(path)) yield Release( id = identifier, @@ -113,9 +112,6 @@ final case class ReleaseDto( def toDomain: Either[DecodeFailure, Release] = toDomainAt(JsonPath.Root) - private def authorAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - author.fold(Right(None))(dto => dto.toDomainAt(at.field("author")).map(Some.apply)) - object ReleaseDto: /** Reads a `Release` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepoCommitDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepoCommitDto.scala index 74712ff..457e78f 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepoCommitDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepoCommitDto.scala @@ -3,9 +3,9 @@ package com.worxbend.codeberg4s.repositories.wire import com.worxbend.codeberg4s.JsonPath import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields +import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.repositories.CommitDetails -import com.worxbend.codeberg4s.repositories.CommitRef /** Forgejo's `RepoCommit` — the `commit` object nested inside a [[CommitDto]]. * @@ -41,7 +41,7 @@ final case class RepoCommitDto( * is reported at `$.commit.tree.sha`. */ def toDomainAt(at: JsonPath): Either[DecodeFailure, CommitDetails] = - treeAt(at).map: treeRef => + Wire.nested(at, "tree", tree)(_.toDomainAt(_)).map: treeRef => CommitDetails( message = message, url = url, @@ -51,9 +51,6 @@ final case class RepoCommitDto( verification = verification.map(_.toDomain), ) - private def treeAt(at: JsonPath): Either[DecodeFailure, Option[CommitRef]] = - tree.fold(Right(None))(dto => dto.toDomainAt(at.field("tree")).map(Some.apply)) - object RepoCommitDto: /** Reads a `RepoCommit` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala index 09c250f..9e22928 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryDto.scala @@ -121,7 +121,7 @@ final case class RepositoryDto( ownerDto <- Wire.required(at, "owner", owner) ownerModel <- ownerDto.toDomainAt(at.field("owner")) ownerHandle <- Wire.validated(at.field("owner"), "login", ownerDto.login)(Owner.from) - parentModel <- parentAt(at) + parentModel <- Wire.nested(at, "parent", parent)(_.toDomainAt(_)) yield Repository( id = identifier, slug = RepoSlug(ownerHandle, repoName), @@ -169,9 +169,6 @@ final case class RepositoryDto( def toDomain: Either[DecodeFailure, Repository] = toDomainAt(JsonPath.Root) - private def parentAt(at: JsonPath): Either[DecodeFailure, Option[Repository]] = - parent.fold(Right(None))(dto => dto.toDomainAt(at.field("parent")).map(Some.apply)) - object RepositoryDto: /** Reads a `Repository` object. Absent and `null` are the same thing for every field; see diff --git a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/TagDto.scala b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/TagDto.scala index a908354..67ba22d 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/TagDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/repositories/wire/TagDto.scala @@ -5,7 +5,6 @@ import com.worxbend.codeberg4s.codec.JsonDecoder import com.worxbend.codeberg4s.codec.JsonFields import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure -import com.worxbend.codeberg4s.repositories.CommitRef import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.Tag import com.worxbend.codeberg4s.repositories.TagName @@ -48,7 +47,7 @@ final case class TagDto( for tagName <- Wire.validated(at, "name", name)(TagName.from) target <- Wire.validated(at, "id", id)(CommitSha.from) - reference <- commitAt(at) + reference <- Wire.nested(at, "commit", commit)(_.toDomainAt(_)) yield Tag( name = tagName, message = message, @@ -63,9 +62,6 @@ final case class TagDto( def toDomain: Either[DecodeFailure, Tag] = toDomainAt(JsonPath.Root) - private def commitAt(at: JsonPath): Either[DecodeFailure, Option[CommitRef]] = - commit.fold(Right(None))(dto => dto.toDomainAt(at.field("commit")).map(Some.apply)) - object TagDto: /** Reads a `Tag` object. */ diff --git a/modules/codec/src/com/worxbend/codeberg4s/users/wire/PublicKeyDto.scala b/modules/codec/src/com/worxbend/codeberg4s/users/wire/PublicKeyDto.scala index ed66ff4..499739a 100644 --- a/modules/codec/src/com/worxbend/codeberg4s/users/wire/PublicKeyDto.scala +++ b/modules/codec/src/com/worxbend/codeberg4s/users/wire/PublicKeyDto.scala @@ -7,7 +7,6 @@ import com.worxbend.codeberg4s.codec.Timestamps import com.worxbend.codeberg4s.codec.Wire import com.worxbend.codeberg4s.core.DecodeFailure import com.worxbend.codeberg4s.users.PublicKey -import com.worxbend.codeberg4s.users.User /** Forgejo's `PublicKey` model, field for field. * @@ -50,7 +49,7 @@ final case class PublicKeyDto( for identifier <- Wire.required(at, "id", id) material <- Wire.required(at, "key", key) - ownerModel <- ownerAt(at) + ownerModel <- Wire.nested(at, "user", user)(_.toDomainAt(_)) yield PublicKey( id = identifier, key = material, @@ -69,9 +68,6 @@ final case class PublicKeyDto( def toDomain: Either[DecodeFailure, PublicKey] = toDomainAt(JsonPath.Root) - private def ownerAt(at: JsonPath): Either[DecodeFailure, Option[User]] = - user.fold(Right(None))(dto => dto.toDomainAt(at.field("user")).map(Some.apply)) - object PublicKeyDto: /** Reads a `PublicKey` object. Absent and `null` are the same thing for every field; see From e7ee18c241d2022629a91fa136f76efca29f307e Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:39:35 +0300 Subject: [PATCH 26/31] refactor(model): promote one shared positive-id validator Every identifier Forgejo expresses as a positive int64 was validated by a copy of the same two lines. Six packages each carried a private helper object -- issues.NumericId, pulls.PullIds, users.social.SocialIds, repositories.admin.AdminIds, repositories.access.AccessIds and repositories.actions.ActionIds -- because each was private to its own package and therefore unreachable from the next, and seven further opaque types spelled the check out inline with a local MinValue. Thirteen copies of one rule is thirteen places to forget the next clause, which is exactly what happened to path-segment validation before PathSegment was promoted to the domain module root. This does the same for the numeric rule: PositiveId lives beside PathSegment, is private[codeberg4s] so every package can reach it, and the helper objects and inline copies are gone. Behaviour is unchanged. A value below 1 is still refused, and the rejection still names the caller's own field, so a bad issue number is still reported as "issueNumber" and a bad run id as "runId". --- .../com/worxbend/codeberg4s/PositiveId.scala | 35 +++++++++++++++++ .../codeberg4s/issues/AttachmentId.scala | 5 ++- .../codeberg4s/issues/CommentId.scala | 3 +- .../codeberg4s/issues/IssueNumber.scala | 3 +- .../worxbend/codeberg4s/issues/LabelId.scala | 3 +- .../codeberg4s/issues/MilestoneId.scala | 3 +- .../codeberg4s/issues/NumericId.scala | 25 ------------ .../codeberg4s/issues/TrackedTimeId.scala | 7 ++-- .../notifications/NotificationThreadId.scala | 6 +-- .../organizations/BlockedUser.scala | 5 +-- .../codeberg4s/organizations/TeamId.scala | 5 +-- .../worxbend/codeberg4s/pulls/PullIds.scala | 27 ------------- .../codeberg4s/pulls/PullRequestNumber.scala | 3 +- .../codeberg4s/pulls/ReviewCommentId.scala | 5 ++- .../worxbend/codeberg4s/pulls/ReviewId.scala | 3 +- .../codeberg4s/repositories/ReleaseId.scala | 5 +-- .../repositories/access/AccessIds.scala | 31 ++------------- .../repositories/actions/ActionIds.scala | 38 +++---------------- .../repositories/admin/AdminIds.scala | 31 ++------------- .../repositories/hooks/HookIds.scala | 6 +-- .../repositories/publishing/AssetId.scala | 10 ++--- .../codeberg4s/users/account/AccountIds.scala | 6 +-- .../codeberg4s/users/social/SocialIds.scala | 35 +++-------------- .../issues/IssueIdentifiersSuite.scala | 4 +- 24 files changed, 93 insertions(+), 211 deletions(-) create mode 100644 modules/domain/src/com/worxbend/codeberg4s/PositiveId.scala delete mode 100644 modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala delete mode 100644 modules/domain/src/com/worxbend/codeberg4s/pulls/PullIds.scala diff --git a/modules/domain/src/com/worxbend/codeberg4s/PositiveId.scala b/modules/domain/src/com/worxbend/codeberg4s/PositiveId.scala new file mode 100644 index 0000000..c67e663 --- /dev/null +++ b/modules/domain/src/com/worxbend/codeberg4s/PositiveId.scala @@ -0,0 +1,35 @@ +package com.worxbend.codeberg4s + +/** Validation shared by every identifier Forgejo expresses as a positive integer. + * + * Almost every row identifier in the API — an issue number, a review id, a run id, a deploy key id, a webhook id — is + * an `int64` that ends up interpolated into a request path. A number cannot forge a path, so this is not a security + * boundary the way [[PathSegment]] is; the point is confusion. A pull request's per-repository `number` and its + * instance-wide `id` are both `Long`, both readable off the same response, and routinely mixed up. `/pulls/0` is a + * request Forgejo answers with a `404` that reads like a missing pull request rather than like a caller bug, so the + * mistake is discovered late and misattributed. + * + * '''It lives in the root package so that the rule is written once.''' Each group of identifiers used to carry its own + * copy — `issues.NumericId`, `pulls.PullIds`, `users.social.SocialIds`, `repositories.admin.AdminIds`, + * `repositories.access.AccessIds`, `repositories.actions.ActionIds` — because each was private to its own package and + * therefore unreachable from the next one, and a further handful of opaque types spelled the same two lines out + * inline. That is the shape [[PathSegment]] was in before it was promoted, and it drifts the same way: a copy is what + * gets forgotten when the rule changes. + * + * A value below `1` is refused rather than clamped, and the rejection names the caller's field, so the failure is + * reported where the wrong number was supplied instead of at the endpoint that would have received it. + */ +private[codeberg4s] object PositiveId: + + /** The smallest identifier the instance can issue. Forgejo's identifiers are database row ids, which start at one. */ + private val MinValue: Long = 1L + + /** Accepts `value` only if it is a positive identifier. + * + * @param field + * the field name to report in a [[ValidationError]] + * @return + * the value unchanged, or a [[ValidationError]] on `field` + */ + def from(field: String, value: Long): Either[ValidationError, Long] = + if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) else Right(value) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/AttachmentId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/AttachmentId.scala index 763cfbc..ce62ae8 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/AttachmentId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/AttachmentId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of an [[IssueAttachment]] — the `{attachment_id}` of @@ -8,7 +9,7 @@ import com.worxbend.codeberg4s.ValidationError * Distinct from [[CommentId]] and from [[IssueNumber]] even though all three are `int64` and all three land in the * same request path: `/issues/12/assets/12` is a perfectly well-formed URL whichever way round the two numbers go, so * a transposition is a `404` at best and a successful read of the wrong attachment at worst. That is the confusion - * this type exists to prevent, exactly as [[NumericId]] describes. + * this type exists to prevent, exactly as [[com.worxbend.codeberg4s.PositiveId]] describes. * * ==Error contract== * @@ -26,7 +27,7 @@ object AttachmentId: * the identifier, or a [[ValidationError]] on the `"attachmentId"` field */ def from(value: Long): Either[ValidationError, AttachmentId] = - NumericId.from("attachmentId", value) + PositiveId.from("attachmentId", value) extension (id: AttachmentId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/CommentId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/CommentId.scala index 26305ad..c77b69a 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/CommentId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/CommentId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[Comment]]. @@ -24,7 +25,7 @@ object CommentId: * the id, or a [[ValidationError]] on the `"commentId"` field */ def from(value: Long): Either[ValidationError, CommentId] = - NumericId.from("commentId", value) + PositiveId.from("commentId", value) extension (id: CommentId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueNumber.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueNumber.scala index 6929719..0d997ba 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/IssueNumber.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/IssueNumber.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The number a repository gives an issue — the `{index}` of `/repos/{owner}/{repo}/issues/{index}`. @@ -26,7 +27,7 @@ object IssueNumber: * the number, or a [[ValidationError]] on the `"issueNumber"` field */ def from(value: Long): Either[ValidationError, IssueNumber] = - NumericId.from("issueNumber", value) + PositiveId.from("issueNumber", value) extension (number: IssueNumber) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/LabelId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/LabelId.scala index f37f732..ad9ab70 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/LabelId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/LabelId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[Label]] — the `{id}` of `/repos/{owner}/{repo}/labels/{id}`. @@ -21,7 +22,7 @@ object LabelId: * the id, or a [[ValidationError]] on the `"labelId"` field */ def from(value: Long): Either[ValidationError, LabelId] = - NumericId.from("labelId", value) + PositiveId.from("labelId", value) extension (id: LabelId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/MilestoneId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/MilestoneId.scala index 81c37fb..38610cd 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/MilestoneId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/MilestoneId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[Milestone]] — the `{id}` of `/repos/{owner}/{repo}/milestones/{id}`. @@ -20,7 +21,7 @@ object MilestoneId: * the id, or a [[ValidationError]] on the `"milestoneId"` field */ def from(value: Long): Either[ValidationError, MilestoneId] = - NumericId.from("milestoneId", value) + PositiveId.from("milestoneId", value) extension (id: MilestoneId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala deleted file mode 100644 index ad9a3d6..0000000 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/NumericId.scala +++ /dev/null @@ -1,25 +0,0 @@ -package com.worxbend.codeberg4s.issues - -import com.worxbend.codeberg4s.ValidationError - -/** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. - * - * [[IssueNumber]], [[LabelId]], [[MilestoneId]] and [[CommentId]] are all `int64` on the wire and all end up - * interpolated into a request path. Unlike [[com.worxbend.codeberg4s.Owner]] a number cannot forge a path, so the - * point here is not escaping but confusion: an issue's per-repository `number` and its instance-wide `id` are both - * `Long` and are routinely mixed up, and `/issues/0` is a request Forgejo answers with a `404` that reads like a - * missing issue rather than like a caller bug. Rejecting non-positive values once, here, keeps both problems out of - * the four opaque types. - */ -private[issues] object NumericId: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) - else Right(value) diff --git a/modules/domain/src/com/worxbend/codeberg4s/issues/TrackedTimeId.scala b/modules/domain/src/com/worxbend/codeberg4s/issues/TrackedTimeId.scala index 19900ee..e48af0a 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/issues/TrackedTimeId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/issues/TrackedTimeId.scala @@ -1,12 +1,13 @@ package com.worxbend.codeberg4s.issues +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of one [[TrackedTime]] entry — the `{id}` of * `/repos/{owner}/{repo}/issues/{index}/times/{id}`. * - * Not an [[IssueNumber]] and not a [[CommentId]], though all three are `int64`; see [[NumericId]] for why they are - * kept apart. + * Not an [[IssueNumber]] and not a [[CommentId]], though all three are `int64`; see + * [[com.worxbend.codeberg4s.PositiveId]] for why they are kept apart. * * ==Error contract== * @@ -24,7 +25,7 @@ object TrackedTimeId: * the identifier, or a [[ValidationError]] on the `"trackedTimeId"` field */ def from(value: Long): Either[ValidationError, TrackedTimeId] = - NumericId.from("trackedTimeId", value) + PositiveId.from("trackedTimeId", value) extension (id: TrackedTimeId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationThreadId.scala b/modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationThreadId.scala index 4f1548e..db8fe87 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationThreadId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationThreadId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.notifications +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[NotificationThread]] — the `{id}` of `/notifications/threads/{id}`. @@ -20,8 +21,6 @@ opaque type NotificationThreadId = Long object NotificationThreadId: - private val MinValue: Long = 1L - /** Parses a notification thread id. * * Rejects anything below `1`. @@ -30,8 +29,7 @@ object NotificationThreadId: * the id, or a [[ValidationError]] on the `"notificationThreadId"` field */ def from(value: Long): Either[ValidationError, NotificationThreadId] = - if value < MinValue then Left(ValidationError("notificationThreadId", s"must be at least $MinValue")) - else Right(value) + PositiveId.from("notificationThreadId", value) extension (id: NotificationThreadId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/organizations/BlockedUser.scala b/modules/domain/src/com/worxbend/codeberg4s/organizations/BlockedUser.scala index 7f3376b..c9f9e9f 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/organizations/BlockedUser.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/organizations/BlockedUser.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.organizations +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError import java.time.Instant @@ -15,8 +16,6 @@ opaque type BlockId = Long object BlockId: - private val MinValue: Long = 1L - /** Parses a block identifier. * * Rejects anything below `1`: Forgejo's identifiers are database row ids and start at one. @@ -25,7 +24,7 @@ object BlockId: * the identifier, or a [[ValidationError]] on the `"blockId"` field */ def from(value: Long): Either[ValidationError, BlockId] = - if value < MinValue then Left(ValidationError("blockId", s"must be at least $MinValue")) else Right(value) + PositiveId.from("blockId", value) extension (id: BlockId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/organizations/TeamId.scala b/modules/domain/src/com/worxbend/codeberg4s/organizations/TeamId.scala index 6e0e255..cc35ca1 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/organizations/TeamId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/organizations/TeamId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.organizations +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a team, as `GET /teams/{id}` takes it. @@ -13,8 +14,6 @@ opaque type TeamId = Long object TeamId: - private val MinValue: Long = 1L - /** Parses a team identifier. * * Rejects zero and negatives: Forgejo's identifiers are database row ids and start at one. @@ -23,7 +22,7 @@ object TeamId: * the identifier, or a [[ValidationError]] on the `"teamId"` field */ def from(value: Long): Either[ValidationError, TeamId] = - if value < MinValue then Left(ValidationError("teamId", s"must be at least $MinValue")) else Right(value) + PositiveId.from("teamId", value) extension (id: TeamId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullIds.scala b/modules/domain/src/com/worxbend/codeberg4s/pulls/PullIds.scala deleted file mode 100644 index a787ff6..0000000 --- a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullIds.scala +++ /dev/null @@ -1,27 +0,0 @@ -package com.worxbend.codeberg4s.pulls - -import com.worxbend.codeberg4s.ValidationError - -/** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. - * - * [[PullRequestNumber]] and [[ReviewId]] are both `int64` on the wire and both end up interpolated into a request - * path. A number cannot forge a path, so the point is confusion rather than escaping: a pull request's per-repository - * `number` and its instance-wide `id` are both `Long` and are routinely mixed up, and `/pulls/0` is a request Forgejo - * answers with a `404` that reads like a missing pull request rather than like a caller bug. - * - * This duplicates `com.worxbend.codeberg4s.issues.NumericId`, which is `private[issues]` and therefore unreachable - * from here. `docs/LEDGER.md` already lists that kind of helper under "helpers awaiting promotion"; the right fix is - * one shared validator in the domain module root, not a widened `issues` internal. - */ -private[pulls] object PullIds: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) - else Right(value) diff --git a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestNumber.scala b/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestNumber.scala index 598c1ec..0856283 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestNumber.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestNumber.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The number a repository gives a pull request — the `{index}` of `/repos/{owner}/{repo}/pulls/{index}`. @@ -32,7 +33,7 @@ object PullRequestNumber: * the number, or a [[ValidationError]] on the `"pullRequestNumber"` field */ def from(value: Long): Either[ValidationError, PullRequestNumber] = - PullIds.from("pullRequestNumber", value) + PositiveId.from("pullRequestNumber", value) extension (number: PullRequestNumber) diff --git a/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewCommentId.scala b/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewCommentId.scala index c11316c..6e29130 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewCommentId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewCommentId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[ReviewComment]] — the `{comment}` of @@ -22,13 +23,13 @@ object ReviewCommentId: /** Parses a review-comment id. * - * Rejects anything below `1`, for the reason [[PullIds]] gives. + * Rejects anything below `1`, for the reason [[com.worxbend.codeberg4s.PositiveId]] gives. * * @return * the id, or a [[ValidationError]] on the `"reviewCommentId"` field */ def from(value: Long): Either[ValidationError, ReviewCommentId] = - PullIds.from("reviewCommentId", value) + PositiveId.from("reviewCommentId", value) extension (id: ReviewCommentId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewId.scala b/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewId.scala index 1d3d951..4dab094 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.pulls +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of a [[Review]] — the `{id}` of `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}`. @@ -20,7 +21,7 @@ object ReviewId: * the id, or a [[ValidationError]] on the `"reviewId"` field */ def from(value: Long): Either[ValidationError, ReviewId] = - PullIds.from("reviewId", value) + PositiveId.from("reviewId", value) extension (id: ReviewId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/ReleaseId.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/ReleaseId.scala index fcedaeb..947ecf4 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/ReleaseId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/ReleaseId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-local identifier of a release, as `GET /repos/{owner}/{repo}/releases/{id}` takes it. @@ -11,8 +12,6 @@ opaque type ReleaseId = Long object ReleaseId: - private val MinValue: Long = 1L - /** Parses a release identifier. * * Rejects zero and negatives: Forgejo's identifiers are database row ids and start at one. @@ -21,7 +20,7 @@ object ReleaseId: * the identifier, or a [[ValidationError]] on the `"releaseId"` field */ def from(value: Long): Either[ValidationError, ReleaseId] = - if value < MinValue then Left(ValidationError("releaseId", s"must be at least $MinValue")) else Right(value) + PositiveId.from("releaseId", value) extension (id: ReleaseId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessIds.scala index db27a60..53ead4b 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessIds.scala @@ -1,33 +1,8 @@ package com.worxbend.codeberg4s.repositories.access +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError -/** Validation shared by the two identifiers in this group that Forgejo expresses as a positive integer. - * - * [[TagProtectionId]] and [[DeployKeyId]] are both `int64` on the wire and both end up interpolated into a request - * path. A number cannot forge a path, so the point is confusion rather than escaping: a tag protection's id, a deploy - * key's id and the `key_id` of the SSH key behind that deploy key are all `Long`, all plausible values for one - * another, and all reachable from the same response. Passing one where another belongs gets a `404` that reads like a - * missing resource rather than like a caller bug — and on an access-control surface, a `404` a caller shrugs at is how - * a rule nobody deleted is believed to be gone. - * - * This duplicates `com.worxbend.codeberg4s.repositories.actions.ActionIds`, `com.worxbend.codeberg4s.issues.NumericId` - * and `com.worxbend.codeberg4s.pulls.PullIds`, each of which is private to its own group and therefore unreachable - * from here. `docs/LEDGER.md` already lists that kind of helper under "helpers awaiting promotion"; the right fix is - * one shared validator in the domain module root, not a widened internal. - */ -private[access] object AccessIds: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) else Right(value) - /** The identifier of one tag protection rule — the `{id}` of `/repos/{owner}/{repo}/tag_protections/{id}`. * * Unlike a branch protection rule, which is addressed by its name, a tag protection rule is addressed by this number. @@ -45,7 +20,7 @@ object TagProtectionId: * the identifier, or a [[ValidationError]] on the `"tagProtectionId"` field */ def from(value: Long): Either[ValidationError, TagProtectionId] = - AccessIds.from("tagProtectionId", value) + PositiveId.from("tagProtectionId", value) extension (id: TagProtectionId) @@ -70,7 +45,7 @@ object DeployKeyId: * the identifier, or a [[ValidationError]] on the `"deployKeyId"` field */ def from(value: Long): Either[ValidationError, DeployKeyId] = - AccessIds.from("deployKeyId", value) + PositiveId.from("deployKeyId", value) extension (id: DeployKeyId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionIds.scala index e443118..8183110 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionIds.scala @@ -1,34 +1,8 @@ package com.worxbend.codeberg4s.repositories.actions +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError -/** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. - * - * [[RunId]], [[JobId]], [[ArtifactId]], [[TaskId]] and [[JobAttempt]] are all `int64` on the wire, and the first three - * end up interpolated into a request path. A number cannot forge a path, so the point is confusion rather than - * escaping: a run's instance-wide `id`, its per-repository `index_in_repo`, the `task_id` of one of its jobs and the - * id of an artifact it produced are all `Long`, all plausible values for one another, and all reachable from the same - * response. Passing one where another belongs gets a `404` that reads like a missing resource rather than like a - * caller bug. - * - * This duplicates `com.worxbend.codeberg4s.issues.NumericId` and `com.worxbend.codeberg4s.pulls.PullIds`, both of - * which are private to their own group and therefore unreachable from here. `docs/LEDGER.md` already lists that kind - * of helper under "helpers awaiting promotion"; the right fix is one shared validator in the domain module root, not a - * widened internal. - */ -private[actions] object ActionIds: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) - else Right(value) - /** The instance-wide identifier of one Actions run — the `{run_id}` of `/repos/{owner}/{repo}/actions/runs/{run_id}`. * * This is '''not''' the run's `index_in_repo`, the per-repository counter the web UI shows as `#42` and the run @@ -45,7 +19,7 @@ object RunId: * the identifier, or a [[ValidationError]] on the `"runId"` field */ def from(value: Long): Either[ValidationError, RunId] = - ActionIds.from("runId", value) + PositiveId.from("runId", value) extension (id: RunId) @@ -67,7 +41,7 @@ object JobId: * the identifier, or a [[ValidationError]] on the `"jobId"` field */ def from(value: Long): Either[ValidationError, JobId] = - ActionIds.from("jobId", value) + PositiveId.from("jobId", value) extension (id: JobId) @@ -87,7 +61,7 @@ object ArtifactId: * the identifier, or a [[ValidationError]] on the `"artifactId"` field */ def from(value: Long): Either[ValidationError, ArtifactId] = - ActionIds.from("artifactId", value) + PositiveId.from("artifactId", value) extension (id: ArtifactId) @@ -109,7 +83,7 @@ object TaskId: * the identifier, or a [[ValidationError]] on the `"taskId"` field */ def from(value: Long): Either[ValidationError, TaskId] = - ActionIds.from("taskId", value) + PositiveId.from("taskId", value) extension (id: TaskId) @@ -132,7 +106,7 @@ object JobAttempt: * the attempt, or a [[ValidationError]] on the `"jobAttempt"` field */ def from(value: Long): Either[ValidationError, JobAttempt] = - ActionIds.from("jobAttempt", value) + PositiveId.from("jobAttempt", value) extension (attempt: JobAttempt) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala index 7360ef5..0d70a94 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala @@ -1,33 +1,10 @@ package com.worxbend.codeberg4s.repositories.admin import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError -/** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. - * - * [[RepositoryId]], [[TopicId]] and [[ActivityId]] are all `int64` on the wire, and the first ends up interpolated - * into a request path. A number cannot forge a path, so the point here is confusion rather than escaping: a - * repository's `id`, a topic's `id` and an activity entry's `id` are all `Long`, all plausible values for one another, - * and two of them can be read off the same response. - * - * This duplicates `com.worxbend.codeberg4s.repositories.actions.ActionIds`, `com.worxbend.codeberg4s.issues.NumericId` - * and `com.worxbend.codeberg4s.pulls.PullIds`, each of which is private to its own group and therefore unreachable - * from here. `docs/LEDGER.md` already lists that kind of helper under "helpers awaiting promotion"; the right fix is - * one shared validator in the domain module root, not a widened internal. - */ -private[admin] object AdminIds: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) else Right(value) - /** The instance-wide identifier of a repository — the `{id}` of `GET /repositories/{id}`. * * The one way to address a repository that survives a rename or a transfer. `owner/name` does not: renaming a @@ -44,7 +21,7 @@ object RepositoryId: * the identifier, or a [[ValidationError]] on the `"repositoryId"` field */ def from(value: Long): Either[ValidationError, RepositoryId] = - AdminIds.from("repositoryId", value) + PositiveId.from("repositoryId", value) extension (id: RepositoryId) @@ -66,7 +43,7 @@ object TopicId: * the identifier, or a [[ValidationError]] on the `"topicId"` field */ def from(value: Long): Either[ValidationError, TopicId] = - AdminIds.from("topicId", value) + PositiveId.from("topicId", value) extension (id: TopicId) @@ -84,7 +61,7 @@ object ActivityId: * the identifier, or a [[ValidationError]] on the `"activityId"` field */ def from(value: Long): Either[ValidationError, ActivityId] = - AdminIds.from("activityId", value) + PositiveId.from("activityId", value) extension (id: ActivityId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala index 032c3bd..c063df8 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala @@ -1,6 +1,7 @@ package com.worxbend.codeberg4s.repositories.hooks import com.worxbend.codeberg4s.PathSegment +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.SegmentLiteral import com.worxbend.codeberg4s.ValidationError @@ -18,8 +19,6 @@ opaque type HookId = Long object HookId: - private val MinValue: Long = 1L - /** Parses a webhook identifier. * * Rejects anything below `1`. A number cannot forge a path, so this is a confusion guard rather than an escaping @@ -29,8 +28,7 @@ object HookId: * the identifier, or a [[ValidationError]] on the `"hookId"` field */ def from(value: Long): Either[ValidationError, HookId] = - if value < MinValue then Left(ValidationError("hookId", s"must be at least $MinValue")) - else Right(value) + PositiveId.from("hookId", value) extension (id: HookId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/AssetId.scala b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/AssetId.scala index 02df1e9..e91382d 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/AssetId.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/AssetId.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.repositories.publishing +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-local identifier of a release attachment, as `/releases/{id}/assets/{attachment_id}` takes it. @@ -19,19 +20,16 @@ opaque type AssetId = Long object AssetId: - /** The smallest identifier Forgejo can issue. Attachment ids are database row ids, which start at one. */ - val MinValue: Long = 1L - /** Parses an attachment identifier. * - * Rejects zero and negatives for the reason [[MinValue]] gives. Nothing else is checked: the value is rendered into - * a path segment as decimal digits, which cannot forge a path. + * Rejects zero and negatives, since attachment ids are database row ids that start at one. Nothing else is checked: + * the value is rendered into a path segment as decimal digits, which cannot forge a path. * * @return * the identifier, or a [[ValidationError]] on the `"assetId"` field */ def from(value: Long): Either[ValidationError, AssetId] = - if value < MinValue then Left(ValidationError("assetId", s"must be at least $MinValue")) else Right(value) + PositiveId.from("assetId", value) extension (id: AssetId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/account/AccountIds.scala b/modules/domain/src/com/worxbend/codeberg4s/users/account/AccountIds.scala index a068177..207e93c 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/account/AccountIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/account/AccountIds.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.account +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError /** The instance-wide identifier of one OAuth2 application — the `{id}` of `/user/applications/oauth2/{id}`. @@ -17,16 +18,13 @@ opaque type OAuth2ApplicationId = Long object OAuth2ApplicationId: - private val MinValue: Long = 1L - /** Parses an application identifier. Rejects anything below `1`. * * @return * the identifier, or a [[ValidationError]] on the `"oauth2ApplicationId"` field */ def from(value: Long): Either[ValidationError, OAuth2ApplicationId] = - if value < MinValue then Left(ValidationError("oauth2ApplicationId", s"must be at least $MinValue")) - else Right(value) + PositiveId.from("oauth2ApplicationId", value) extension (id: OAuth2ApplicationId) diff --git a/modules/domain/src/com/worxbend/codeberg4s/users/social/SocialIds.scala b/modules/domain/src/com/worxbend/codeberg4s/users/social/SocialIds.scala index 5c18fba..ca88d59 100644 --- a/modules/domain/src/com/worxbend/codeberg4s/users/social/SocialIds.scala +++ b/modules/domain/src/com/worxbend/codeberg4s/users/social/SocialIds.scala @@ -1,33 +1,8 @@ package com.worxbend.codeberg4s.users.social +import com.worxbend.codeberg4s.PositiveId import com.worxbend.codeberg4s.ValidationError -/** Validation shared by every identifier in this group that Forgejo expresses as a positive integer. - * - * [[SshKeyId]], [[GpgKeyId]], [[AccessTokenId]] and [[BlockId]] are all `int64` on the wire and three of the four end - * up interpolated into a request path. A number cannot forge a path, so the point is confusion rather than escaping: a - * key's row id, a token's row id and a block's row id are all `Long`, all plausible values for one another, and all - * reachable from responses a caller holds at the same time. Passing one where another belongs gets a `404` that reads - * like a missing resource rather than like a caller bug. - * - * This duplicates `com.worxbend.codeberg4s.repositories.actions.ActionIds`, `com.worxbend.codeberg4s.issues.NumericId` - * and `com.worxbend.codeberg4s.pulls.PullIds`, each of which is private to its own group and therefore unreachable - * from here. `docs/LEDGER.md` already lists that kind of helper under "helpers awaiting promotion"; the right fix is - * one shared validator in the domain module root, not a widened internal. - */ -private[social] object SocialIds: - - private val MinValue: Long = 1L - - /** Accepts `value` only if it is a positive identifier. - * - * @param field - * the field name to report in a [[ValidationError]] - */ - def from(field: String, value: Long): Either[ValidationError, Long] = - if value < MinValue then Left(ValidationError(field, s"must be at least $MinValue")) - else Right(value) - /** The row identifier of one registered SSH key — the `{id}` of `/user/keys/{id}`. * * [[com.worxbend.codeberg4s.users.PublicKey.id]] is a bare `Long`, because that model predates this group and is @@ -44,7 +19,7 @@ object SshKeyId: * the identifier, or a [[ValidationError]] on the `"sshKeyId"` field */ def from(value: Long): Either[ValidationError, SshKeyId] = - SocialIds.from("sshKeyId", value) + PositiveId.from("sshKeyId", value) extension (id: SshKeyId) @@ -68,7 +43,7 @@ object GpgKeyId: * the identifier, or a [[ValidationError]] on the `"gpgKeyId"` field */ def from(value: Long): Either[ValidationError, GpgKeyId] = - SocialIds.from("gpgKeyId", value) + PositiveId.from("gpgKeyId", value) extension (id: GpgKeyId) @@ -90,7 +65,7 @@ object AccessTokenId: * the identifier, or a [[ValidationError]] on the `"accessTokenId"` field */ def from(value: Long): Either[ValidationError, AccessTokenId] = - SocialIds.from("accessTokenId", value) + PositiveId.from("accessTokenId", value) extension (id: AccessTokenId) @@ -113,7 +88,7 @@ object BlockId: * the identifier, or a [[ValidationError]] on the `"blockId"` field */ def from(value: Long): Either[ValidationError, BlockId] = - SocialIds.from("blockId", value) + PositiveId.from("blockId", value) extension (id: BlockId) diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueIdentifiersSuite.scala b/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueIdentifiersSuite.scala index 3bf81d7..748a5a1 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueIdentifiersSuite.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueIdentifiersSuite.scala @@ -5,8 +5,8 @@ import munit.FunSuite /** The four numeric identifiers this group owns, and the one rule they share. * * They are tested together rather than in four near-identical suites because the behaviour under test is - * [[NumericId]]'s; what differs between them is only the field name a rejection reports, and a caller branches on that - * name. + * [[com.worxbend.codeberg4s.PositiveId]]'s; what differs between them is only the field name a rejection reports, and + * a caller branches on that name. */ final class IssueIdentifiersSuite extends FunSuite: From 00372688b7c9e416c47f6b400d6f5560362efbaf Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:42:30 +0300 Subject: [PATCH 27/31] refactor(core): serve binary calls from the shared pipeline path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `callBinary` had its own copy of the send/observe/settle sequence — `binaryAttempt` and `settleBinary` repeated, line for line, what `attemptOnce` and `settle` already did for a textual call. Two copies of a cross-cutting rule is two places for it to drift: a fix to how `Retry-After` is honoured, or to the order telemetry hooks fire in, had to be made twice and nothing failed if it was made once. The three methods after the send need only four facts about a response — its status, the echoed request id, the requested backoff, and the body to read on the failure path. Those are now named by a private `ApiPipeline.ResponseFacts[R]`, with one instance for `CodebergResponse` and one for `BinaryResponse`, and `perform` takes the port method to call so the same code drives `HttpPort.send` and `BinaryHttpPort.sendBinary`. No behaviour changes and no published signature changes: `callBinary` still answers a `BinaryResponse`, still retries as `IdempotentOnly`, and still hands a successful body back without decoding it. --- .../codeberg4s/core/ApiPipeline.scala | 139 ++++++++++-------- 1 file changed, 80 insertions(+), 59 deletions(-) diff --git a/modules/core/src/com/worxbend/codeberg4s/core/ApiPipeline.scala b/modules/core/src/com/worxbend/codeberg4s/core/ApiPipeline.scala index 56bf3a5..42ad65c 100644 --- a/modules/core/src/com/worxbend/codeberg4s/core/ApiPipeline.scala +++ b/modules/core/src/com/worxbend/codeberg4s/core/ApiPipeline.scala @@ -81,7 +81,7 @@ final class ApiPipeline[F[_]]( * the decoded value, or a [[com.worxbend.codeberg4s.CodebergError]] in `F`'s error channel */ def call[A](request: CodebergRequest, eligibility: RetryEligibility)(using decode: Decode[A]): F[A] = - perform(request, eligibility)((ctx, response) => decoded[A](ctx, response.body)) + perform(request, eligibility, http.send)((ctx, response) => decoded[A](ctx, response.body)) /** As [[call]], but for an endpoint that answers `204` or whose body is deliberately ignored. * @@ -89,7 +89,7 @@ final class ApiPipeline[F[_]]( * call. Non-2xx statuses are classified exactly as in [[call]]. */ def callUnit(request: CodebergRequest, eligibility: RetryEligibility): F[Unit] = - perform(request, eligibility)((_, _) => Right(())) + perform(request, eligibility, http.send)((_, _) => Right(())) /** As [[call]], but assembles a [[com.worxbend.codeberg4s.paging.Page]] from the response's paging headers. * @@ -102,7 +102,7 @@ final class ApiPipeline[F[_]]( * the window that was requested; it is kept on the returned page */ def callPage[A](request: CodebergRequest, params: PageParams)(using decode: Decode[Vector[A]]): F[Page[A]] = - perform(request, RetryEligibility.IdempotentOnly): (ctx, response) => + perform(request, RetryEligibility.IdempotentOnly, http.send): (ctx, response) => decoded[Vector[A]](ctx, response.body).map(items => Pages.from(response, params, items)) /** Sends `request` and returns its body as bytes, for the endpoints that answer an archive rather than text. @@ -117,50 +117,25 @@ final class ApiPipeline[F[_]]( * Always [[RetryEligibility.IdempotentOnly]] — every endpoint that answers bytes in this API is a `GET`. */ def callBinary(request: CodebergRequest, binary: BinaryHttpPort[F]): F[BinaryResponse] = - val uri = redactedUri(request) - val attempts = engine.runWith(request.operation, request.method, RetryEligibility.IdempotentOnly)(_ => - binaryAttempt(request, uri, binary) - ) - exec.attempt(attempts).flatMap: - case Right(value) => exec.pure(value) - case Left(error) => reportFinal(request, error).flatMap(_ => exec.raise(error)) - - private def binaryAttempt( - request: CodebergRequest, - uri: String, - binary: BinaryHttpPort[F], - ): F[AttemptOutcome[BinaryResponse]] = - timer.nowMillis.flatMap: started => - observe(telemetry.onRequest(contextOf(request, uri, None, 0L))).flatMap: _ => - binary.sendBinary(request, uri).flatMap: sent => - timer.nowMillis.flatMap: finished => - settleBinary(request, uri, finished - started, sent) + perform(request, RetryEligibility.IdempotentOnly, binary.sendBinary)((_, response) => Right(response)) - private def settleBinary( + /** Runs one call to completion: retry the attempts, then report and raise whatever the engine gave up with. + * + * `send` is what makes this serve both transports. It is the port method to call — [[HttpPort.send]] or + * [[BinaryHttpPort.sendBinary]] — and `R` is whatever that port answers with; everything downstream of the send + * reads a response only through [[ApiPipeline.ResponseFacts]], so retry, telemetry, status mapping and `CallContext` + * are written once and cannot drift between a textual call and a download. + */ + private def perform[R, A]( request: CodebergRequest, - uri: String, - elapsedMs: Long, - sent: Either[TransportFailure, BinaryResponse], - ): F[AttemptOutcome[BinaryResponse]] = - sent match - case Left(failure) => - val ctx = contextOf(request, uri, None, elapsedMs) - failedWith(ctx, CodebergError.Transport(ctx, failure.cause), None) - case Right(response) => - val ctx = contextOf(request, uri, response.requestId, elapsedMs) - observe(telemetry.onResponse(ctx, response.status)).flatMap: _ => - if StatusMapping.isSuccess(response.status) then exec.pure(AttemptOutcome.succeeded(response)) - else - val body = ResponseBody.of(response.bytes, ResponseBody.charsetOf(response.contentType)) - val error = StatusMapping.toError(ctx, response.status, parsedErrorBody(body)) - failedWith(ctx, error, response.retryAfter) - - private def perform[A](request: CodebergRequest, eligibility: RetryEligibility)( - onSuccess: (CallContext, CodebergResponse) => Either[CodebergError, A] - ): F[A] = + eligibility: RetryEligibility, + send: (CodebergRequest, String) => F[Either[TransportFailure, R]], + )( + onSuccess: (CallContext, R) => Either[CodebergError, A] + )(using facts: ApiPipeline.ResponseFacts[R]): F[A] = val uri = redactedUri(request) val attempts = engine.runWith(request.operation, request.method, eligibility)(_ => - attemptOnce(request, uri, onSuccess) + attemptOnce(request, uri, send, onSuccess) ) exec.attempt(attempts).flatMap: case Right(value) => exec.pure(value) @@ -176,40 +151,42 @@ final class ApiPipeline[F[_]]( private def redactedUri(request: CodebergRequest): String = Redaction.uri(config.baseUri.value, request.path, request.query) - private def attemptOnce[A]( + private def attemptOnce[R, A]( request: CodebergRequest, uri: String, - onSuccess: (CallContext, CodebergResponse) => Either[CodebergError, A], - ): F[AttemptOutcome[A]] = + send: (CodebergRequest, String) => F[Either[TransportFailure, R]], + onSuccess: (CallContext, R) => Either[CodebergError, A], + )(using facts: ApiPipeline.ResponseFacts[R]): F[AttemptOutcome[A]] = timer.nowMillis.flatMap: started => observe(telemetry.onRequest(contextOf(request, uri, None, 0L))).flatMap: _ => - http.send(request, uri).flatMap: sent => + send(request, uri).flatMap: sent => timer.nowMillis.flatMap: finished => settle(request, uri, finished - started, sent, onSuccess) - private def settle[A]( + private def settle[R, A]( request: CodebergRequest, uri: String, elapsedMs: Long, - sent: Either[TransportFailure, CodebergResponse], - onSuccess: (CallContext, CodebergResponse) => Either[CodebergError, A], - ): F[AttemptOutcome[A]] = + sent: Either[TransportFailure, R], + onSuccess: (CallContext, R) => Either[CodebergError, A], + )(using facts: ApiPipeline.ResponseFacts[R]): F[AttemptOutcome[A]] = sent match case Left(failure) => val ctx = contextOf(request, uri, None, elapsedMs) failedWith(ctx, CodebergError.Transport(ctx, failure.cause), None) case Right(response) => - val ctx = contextOf(request, uri, response.requestId, elapsedMs) - observe(telemetry.onResponse(ctx, response.status)).flatMap: _ => - if StatusMapping.isSuccess(response.status) then succeed(ctx, response, onSuccess) + val ctx = contextOf(request, uri, facts.requestId(response), elapsedMs) + val status = facts.status(response) + observe(telemetry.onResponse(ctx, status)).flatMap: _ => + if StatusMapping.isSuccess(status) then succeed(ctx, response, onSuccess) else - val error = StatusMapping.toError(ctx, response.status, parsedErrorBody(response.body)) - failedWith(ctx, error, response.retryAfter) + val error = StatusMapping.toError(ctx, status, parsedErrorBody(facts.errorBody(response))) + failedWith(ctx, error, facts.retryAfter(response)) - private def succeed[A]( + private def succeed[R, A]( ctx: CallContext, - response: CodebergResponse, - onSuccess: (CallContext, CodebergResponse) => Either[CodebergError, A], + response: R, + onSuccess: (CallContext, R) => Either[CodebergError, A], ): F[AttemptOutcome[A]] = onSuccess(ctx, response) match case Right(value) => exec.pure(AttemptOutcome.succeeded(value)) @@ -254,6 +231,50 @@ final class ApiPipeline[F[_]]( object ApiPipeline: + /** The little a [[ApiPipeline]] needs to know about a response, so that one pipeline serves both transports. + * + * [[HttpPort]] answers with a [[CodebergResponse]] and [[BinaryHttpPort]] with a [[BinaryResponse]]. Those are + * different types, but everything the pipeline does after the send — build the + * [[com.worxbend.codeberg4s.CallContext]], report the status, classify a non-2xx, honour `Retry-After` — needs only + * these four facts. Naming them here lets the send, retry and telemetry sequence be written once instead of once per + * transport, which is what stops the two copies from quietly disagreeing about, say, whether a download honours + * `Retry-After`. + * + * @tparam R + * the response type a port hands back + */ + private trait ResponseFacts[R]: + + /** The HTTP status, which [[StatusMapping]] turns into success or a failure. */ + def status(response: R): Int + + /** The instance's correlation id, when it echoed one; copied onto every call context. */ + def requestId(response: R): Option[String] + + /** The server-requested backoff, when it asked for one. */ + def retryAfter(response: R): Option[FiniteDuration] + + /** The body as text '''for the failure path only'''. + * + * An error payload is JSON text on every endpoint, including one whose success body is an archive, so this is + * always readable. A successful body never goes through here — a download's bytes are handed back untouched. + */ + def errorBody(response: R): ResponseBody + + private given ResponseFacts[CodebergResponse] with + def status(response: CodebergResponse): Int = response.status + def requestId(response: CodebergResponse): Option[String] = response.requestId + def retryAfter(response: CodebergResponse): Option[FiniteDuration] = response.retryAfter + def errorBody(response: CodebergResponse): ResponseBody = response.body + + private given ResponseFacts[BinaryResponse] with + def status(response: BinaryResponse): Int = response.status + def requestId(response: BinaryResponse): Option[String] = response.requestId + def retryAfter(response: BinaryResponse): Option[FiniteDuration] = response.retryAfter + + def errorBody(response: BinaryResponse): ResponseBody = + ResponseBody.of(response.bytes, ResponseBody.charsetOf(response.contentType)) + /** What a body is reported as when its [[Decode]] declared itself [[Decode.sensitive]]. * * A fixed string, so nothing about the payload survives into it, but not an empty one: a reader still has to be able From 4f3e289bce3791011cc89be2672c2589030b1f1c Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 17:59:32 +0300 Subject: [PATCH 28/31] test(client): share one stub-backend harness across the API suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every API suite in modules/client carried its own copy of the same preamble: build a BackendStub, wrap it in an ApiPipeline with an anonymous config and a prompt retry policy, close the timer afterwards, and define pathOf/queryOf/methodOf/bodyOf/window/orFail plus the two-rail failure assertions. Four group-local harnesses (the issue, organisation, account and social groups) had already been factored out of parts of it, so the same twenty lines existed in five slightly different versions and thirteen suites still inlined them by hand. Copies drift. One copy asserted the query string, another had stopped; one named the retry-count helper callCount, another attemptsOn; the "empty" string sttp renders for a bodyless request was spelled out in three places and reasoned about from scratch in a fourth. None of that is behaviour under test — it is scaffolding, and scaffolding that disagrees with itself hides real differences between suites. ClientSuiteHarness is now the single place that scaffolding lives. It mixes into a FunSuite, hands out the stub-backend builders, the request accessors, the pagination window, the smart-constructor unwrapper, the rails-agree assertion and onPipeline, which builds the pipeline and releases the timer whatever the outcome. Each suite keeps only what is specific to it: its fixtures, its stubbed response bodies, and a one-line onApi that constructs its own API class on that pipeline. The three surviving group harnesses now extend it and hold nothing but their group's fixtures and error bodies; SocialApiHarness held nothing else at all and became the shared harness. Two renames fall out of the merge. A suite whose Root meant "the prefix my endpoints hang off" rather than "the instance's API root" now calls that Endpoint and derives it from the shared Root, so the two ideas cannot be confused. callCount and failingThenSucceeding are gone in favour of the harness names attemptsOn and flakyThen. No test changed what it asserts; the suites are 1757 lines shorter. --- ...Harness.scala => ClientSuiteHarness.scala} | 103 +++--- .../codeberg4s/CodebergClientSuite.scala | 74 +--- .../codeberg4s/issues/IssueApiSuite.scala | 179 ++-------- .../codeberg4s/issues/IssueLaneHarness.scala | 156 +------- .../miscellaneous/MiscellaneousApiSuite.scala | 183 +++------- .../notifications/NotificationApiSuite.scala | 178 ++-------- .../OrganizationAdminApiSuite.scala | 12 +- .../organizations/OrganizationApiSuite.scala | 175 ++------- .../OrganizationHookApiSuite.scala | 6 +- .../OrganizationLabelApiSuite.scala | 6 +- .../organizations/OrganizationStubs.scala | 178 +--------- .../OrganizationTeamApiSuite.scala | 14 +- .../actions/OrganizationActionApiSuite.scala | 179 +++------- .../pulls/PullRequestApiSuite.scala | 170 ++------- .../pulls/PullRequestReviewApiSuite.scala | 231 ++++-------- .../access/RepositoryAccessApiSuite.scala | 258 ++++---------- .../actions/RepositoryActionApiSuite.scala | 271 +++++--------- .../admin/RepositoryAdminApiSuite.scala | 334 ++++++------------ .../gitdata/RepositoryGitApiSuite.scala | 220 +++--------- .../RepositoryPublishingApiSuite.scala | 211 +++-------- .../users/account/AccountApiSuite.scala | 160 +-------- .../users/account/UserAccountApiSuite.scala | 32 +- .../users/account/UserActionApiSuite.scala | 18 +- .../account/UserApplicationApiSuite.scala | 8 +- .../users/account/UserHookApiSuite.scala | 8 +- .../users/account/UserQuotaApiSuite.scala | 2 +- .../users/social/UserKeyApiSuite.scala | 27 +- .../users/social/UserSocialApiSuite.scala | 35 +- .../users/social/UserTokenApiSuite.scala | 33 +- 29 files changed, 852 insertions(+), 2609 deletions(-) rename modules/client/test/src/com/worxbend/codeberg4s/{users/social/SocialApiHarness.scala => ClientSuiteHarness.scala} (59%) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/SocialApiHarness.scala b/modules/client/test/src/com/worxbend/codeberg4s/ClientSuiteHarness.scala similarity index 59% rename from modules/client/test/src/com/worxbend/codeberg4s/users/social/SocialApiHarness.scala rename to modules/client/test/src/com/worxbend/codeberg4s/ClientSuiteHarness.scala index bd04752..61fbba1 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/SocialApiHarness.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/ClientSuiteHarness.scala @@ -1,10 +1,5 @@ -package com.worxbend.codeberg4s.users.social +package com.worxbend.codeberg4s -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig -import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.client.FutureExec import com.worxbend.codeberg4s.client.FutureTimer @@ -20,6 +15,7 @@ import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend +import sttp.client4.GenericRequest import sttp.client4.Response import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend @@ -34,30 +30,36 @@ import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.concurrent.duration.DurationInt -/** The stub backend, the pipeline and the assertions the three suites of this group share. +/** The stub backend, the pipeline and the request assertions every API suite in this module shares. * - * Three API classes sit on one pipeline and one retry policy, and each of them needs the same six questions asked of a - * recorded request — which method, which path, which query, which body, how many attempts, and do the two rails agree. - * Writing that once is what stops the three suites from drifting into three slightly different notions of "the request - * that was sent"; the alternative was copying sixty lines of harness three times, which is how one copy quietly stops - * asserting the query string. + * Each API class is exercised the same way: answer it from a [[sttp.client4.testing.BackendStub]], record what it + * dialled, and ask the same handful of questions of the recording — which method, which path, which query parameters, + * which body, how many attempts, and whether the two rails (the convenience one that raises and the `attempt` one that + * returns an `Either`) describe a failure identically. Before this trait existed each suite carried its own copy of + * that sixty-line preamble, which is how one copy quietly stops asserting the query string while the others still do. * - * Nothing here opens a socket. [[onBackend]] builds the whole pipeline over a [[sttp.client4.testing.BackendStub]] and - * releases the timer whatever the outcome. + * '''Nothing here opens a socket.''' The subject of every suite mixing this in is the wiring, never the network. + * + * A suite mixes it in and adds only what is specific to its own surface — its fixtures, its response bodies, and a + * one-line `onApi` that builds its API class on the pipeline [[onPipeline]] hands it. */ -trait SocialApiHarness: +trait ClientSuiteHarness: self: FunSuite => - /** The pool munit already runs the suite's futures on; declared here so the three suites do not each declare one. */ + /** The execution context every suite's futures run on — munit's own, so a hung assertion fails the test rather than + * the JVM. + */ given executionContext: ExecutionContext = munitExecutionContext - /** The effect instance every API class in this group is constructed with. */ + /** The effect the APIs are built with. Exposed rather than kept inside [[onPipeline]] because a suite constructs its + * own API instance at the call site, where the context parameter has to be resolvable. + */ given exec: Exec[Future] = FutureExec() - /** The instance every suite in this group pretends to talk to. */ + /** The instance every suite pretends to talk to. */ val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - /** The prefix every asserted path starts with. */ + /** The prefix every asserted path starts with — [[Instance]] as the plain string an assertion interpolates. */ val Root: String = "https://forge.example/api/v1" /** How sttp renders a request that carries no body at all, which is what [[bodyOf]] answers for one. @@ -75,45 +77,64 @@ trait SocialApiHarness: def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - /** A backend that answers `first` once and `rest` from then on — how a retry is made observable. */ + /** A backend that answers one `503` and then `status` with `body` — how a retry is made observable. */ + def flakyThen(status: Int, body: String): BackendStub[Future] = + cycling(stub(503, ""), stub(status, body)) + + /** A backend that answers `first` once and `rest` from then on. */ def cycling(first: Response[StubBody], rest: Response[StubBody]): BackendStub[Future] = BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic(first, rest) - /** A response with a status and a body, for [[cycling]]. */ + /** A single canned response with a status and a body, for [[cycling]]. */ def stub(status: Int, body: String): Response[StubBody] = ResponseStub.adjust(body, StatusCode(status)) - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ + /** The URI the first recorded request dialled, query string and all. */ + def dialled(backend: RecordingBackend): String = + firstRequest(backend).uri.toString + + /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because + * `.scalafix.conf` bans universal equality outright. + */ def pathOf(backend: RecordingBackend): String = val uri = dialled(backend) val query = uri.indexOf('?') if query < 0 then uri else uri.take(query) - /** The query parameters of the first request that reached `backend`, in wire order. */ + /** The query parameters of the first recorded request, in the order they were sent. */ def queryOf(backend: RecordingBackend): List[(String, String)] = firstRequest(backend).uri.params.toSeq.toList - /** The method of the first request that reached `backend`. */ + /** The HTTP method of the first recorded request. */ def methodOf(backend: RecordingBackend): String = firstRequest(backend).method.method - /** The body of the first request that reached `backend`, as the string it was rendered to. */ + /** The body of the first recorded request, as sttp renders it for display. */ def bodyOf(backend: RecordingBackend): String = firstRequest(backend).body.show.stripPrefix("string: ") - /** How many requests reached `backend` — one more than zero retries. */ + /** The `Content-Type` the first recorded request declared, if it declared one. */ + def contentTypeOf(backend: RecordingBackend): Option[String] = + firstRequest(backend).header("Content-Type") + + /** How many requests reached `backend` — one more than the number of retries. */ def attemptsOn(backend: RecordingBackend): Int = backend.allInteractions.size - /** A window of `size` items starting at page `page`. */ + /** The first request recorded by `backend`, for an assertion no named accessor above covers. */ + def firstRequest(backend: RecordingBackend): GenericRequest[?, ?] = + backend.allInteractions.headOption match + case Some((request, _)) => request + case None => fail("no request reached the backend") + + /** A pagination window of `size` items starting at page `page`. */ def window(page: Int, size: Int): PageParams = PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - /** Builds `api` on a pipeline over `backend`, and releases the timer whatever the outcome. */ - def onBackend[A, B](backend: Backend[Future])(build: ApiPipeline[Future] => B)(use: B => Future[A]): Future[A] = - val config = CodebergConfig(Auth.Anonymous) - .copy(baseUri = Instance, retry = SocialApiHarness.PromptRetry) + /** Builds the pipeline an API sits on over `backend`, and releases the timer whatever the outcome. */ + def onPipeline[A](backend: Backend[Future])(use: ApiPipeline[Future] => Future[A]): Future[A] = + val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = ClientSuiteHarness.PromptRetry) val timer = FutureTimer() val pipeline = ApiPipeline[Future]( @@ -124,7 +145,7 @@ trait SocialApiHarness: ApiErrorBodyCodec.parse, ) - use(build(pipeline)).transform: outcome => + use(pipeline).transform: outcome => timer.close() outcome @@ -148,22 +169,20 @@ trait SocialApiHarness: case Left(CodebergError.Api(ctx, _, _)) => ctx.operation case other => fail(s"expected an Api failure, got $other") + /** The per-field messages a failed call carried back from the forge. */ + def detailsOf[A](result: Either[CodebergError, A]): List[String] = + result match + case Left(CodebergError.Api(_, _, body)) => body.errors + case other => fail(s"expected an Api failure, got $other") + /** Unwraps a smart constructor in a fixture, failing the test rather than the call under test. */ def orFail[A](result: Either[ValidationError, A]): A = result match case Right(value) => value case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - private def dialled(backend: RecordingBackend): String = - firstRequest(backend).uri.toString - - private def firstRequest(backend: RecordingBackend): sttp.client4.GenericRequest[?, ?] = - backend.allInteractions.headOption match - case Some((request, _)) => request - case None => fail("no request reached the backend") - -/** The retry policy the group's suites run under. */ -object SocialApiHarness: +/** The retry policy every suite mixing [[ClientSuiteHarness]] in runs under. */ +object ClientSuiteHarness: /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ val PromptRetry: RetryPolicy = RetryPolicy( diff --git a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala index 607b4f3..0e30b63 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala @@ -5,10 +5,7 @@ import com.worxbend.codeberg4s.auth.Auth import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.repositories.Repository import com.worxbend.codeberg4s.repositories.RepositoryApi -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy import com.worxbend.codeberg4s.syntax.discard import com.worxbend.codeberg4s.transport.SttpHttpPort @@ -37,26 +34,22 @@ import java.util.concurrent.Executors * captures. The payloads below are therefore small hand-written bodies chosen to exercise the seams: what a caller * gets back, what each rail does with a failure, whether a retry really re-sends, and what `close` owns. */ -final class CodebergClientSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class CodebergClientSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("forgejo")) private val Name: RepoName = orFail(RepoName.from("forgejo")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - test("version maps the instance's payload to a domain value"): - onStub(responding(200, CodebergClientSuite.VersionBody)): client => + onClient(responding(200, CodebergClientSuite.VersionBody)): client => client.version.get().map(version => assertEquals(version, CodebergClientSuite.ExpectedVersion)) test("the typed rail returns the same version as a Right"): - onStub(responding(200, CodebergClientSuite.VersionBody)): client => + onClient(responding(200, CodebergClientSuite.VersionBody)): client => client.version.attempt.get().map(result => assertEquals(result, Right(CodebergClientSuite.ExpectedVersion))) test("repos.get maps the instance's payload to a domain repository"): - onStub(responding(200, CodebergClientSuite.RepositoryBody)): client => + onClient(responding(200, CodebergClientSuite.RepositoryBody)): client => client.repos.get(Handle, Name).map: repository => assertEquals(repository.id, 12345L) assertEquals(repository.slug.value, "forgejo/forgejo") @@ -67,19 +60,19 @@ final class CodebergClientSuite extends FunSuite: test("repos.get targets /repos/{owner}/{repo} on the configured instance"): val backend = RecordingBackend(responding(200, CodebergClientSuite.RepositoryBody)) - onBackend(backend, configFor(Auth.Anonymous)): client => + onClientWith(backend, configFor(Auth.Anonymous)): client => client.repos .get(Handle, Name) .map(_ => assertEquals(dialled(backend), "https://forge.example/api/v1/repos/forgejo/forgejo")) test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, CodebergClientSuite.NotFoundBody)): client => + onClient(responding(404, CodebergClientSuite.NotFoundBody)): client => client.repos.get(Handle, Name).failed.map: case CodebergException(error) => assertEquals(summary(error), CodebergClientSuite.ExpectedNotFound) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, CodebergClientSuite.NotFoundBody)): client => + onClient(responding(404, CodebergClientSuite.NotFoundBody)): client => for raised <- client.repos.get(Handle, Name).failed typed <- client.repos.attempt.get(Handle, Name) @@ -93,13 +86,13 @@ final class CodebergClientSuite extends FunSuite: ) ) - onBackend(backend, configFor(Auth.Anonymous)): client => + onClientWith(backend, configFor(Auth.Anonymous)): client => client.repos.get(Handle, Name).map: repository => assertEquals(repository.slug.value, "forgejo/forgejo") assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, CodebergClientSuite.UnexpectedBody)): client => + onClient(responding(200, CodebergClientSuite.UnexpectedBody)): client => client.repos.attempt.get(Handle, Name).map: case Left(CodebergError.DecodingFailed(_, snippet, path, _)) => assertEquals(path.render, "$.id") @@ -173,47 +166,22 @@ final class CodebergClientSuite extends FunSuite: test("a configured token appears in nothing the caller can see about a failure"): val config = configFor(Auth.Token(orFail(ApiToken.from(CodebergClientSuite.Secret)))) - onBackend(responding(404, CodebergClientSuite.NotFoundBody), config): client => + onClientWith(responding(404, CodebergClientSuite.NotFoundBody), config): client => client.repos.get(Handle, Name).failed.map: thrown => val rendered = List(thrown.getMessage, thrown.toString, thrown.getStackTrace.mkString(" ")).mkString(" | ") assert(!rendered.contains(CodebergClientSuite.Secret), rendered) - // --- assertions ----------------------------------------------------------- - - /** Both rails must report the same failure, so the choice between them is a choice of style and nothing else. */ - private def assertRailsAgree(raised: Throwable, typed: Either[CodebergError, Repository]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - assertEquals(summary(materialised), CodebergClientSuite.ExpectedNotFound) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - /** An `Api` failure projected onto the parts that do not depend on wall-clock time, so two calls are comparable. */ - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - // --- fixtures ------------------------------------------------------------- private def configFor(auth: Auth): CodebergConfig = - CodebergConfig(auth).copy(baseUri = Instance, retry = CodebergClientSuite.PromptRetry) - - private def responding(status: Int, body: String): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status))) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") + CodebergConfig(auth).copy(baseUri = Instance, retry = ClientSuiteHarness.PromptRetry) /** Runs `use` against an anonymous client on `backend`, closing the client whatever the outcome. */ - private def onStub[A](backend: Backend[Future])(use: CodebergClient => Future[A]): Future[A] = - onBackend(backend, configFor(Auth.Anonymous))(use) + private def onClient[A](backend: Backend[Future])(use: CodebergClient => Future[A]): Future[A] = + onClientWith(backend, configFor(Auth.Anonymous))(use) - private def onBackend[A](backend: Backend[Future], config: CodebergConfig)( + private def onClientWith[A](backend: Backend[Future], config: CodebergConfig)( use: CodebergClient => Future[A] ): Future[A] = val client = CodebergClient.usingBackend(config, backend) @@ -222,11 +190,6 @@ final class CodebergClientSuite extends FunSuite: client.close() outcome - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object CodebergClientSuite: @@ -269,12 +232,3 @@ object CodebergClientSuite: private val ExpectedNotFound: (String, Int, Option[String]) = (RepositoryApi.GetOperation, 404, Some("The target couldn't be found.")) - - /** Retries promptly and predictably: the default policy would make the retry test take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala index 8f362f0..13a36bc 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueApiSuite.scala @@ -1,25 +1,12 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.testing.BackendStub @@ -30,9 +17,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt import java.time.Instant @@ -42,9 +27,7 @@ import java.time.Instant * does with a failure, and what the paging headers are allowed to decide. Decoding itself is asserted against the * golden captures in `modules/codec`, so the payloads here are small hand-written bodies chosen to exercise a seam. */ -final class IssueApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class IssueApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("Codeberg")) @@ -54,12 +37,10 @@ final class IssueApiSuite extends FunSuite: private val Release: MilestoneId = orFail(MilestoneId.from(3109L)) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - // --- reads ---------------------------------------------------------------- test("a single-issue read maps the instance's payload to a domain issue"): - onStub(responding(200, IssueApiSuite.IssueBody)): api => + onApi(responding(200, IssueApiSuite.IssueBody)): api => api.get(Handle, Name, Number).map: issue => assertEquals(issue.number.value, 2966L) assertEquals(issue.title, "Bye") @@ -69,7 +50,7 @@ final class IssueApiSuite extends FunSuite: test("a single-issue read targets /repos/{owner}/{repo}/issues/{index} on the configured instance"): val backend = RecordingBackend(responding(200, IssueApiSuite.IssueBody)) - onBackend(backend): api => + onApi(backend): api => api .get(Handle, Name, Number) .map(_ => assertEquals(dialled(backend), "https://forge.example/api/v1/repos/Codeberg/Community/issues/2966")) @@ -77,7 +58,7 @@ final class IssueApiSuite extends FunSuite: test("issues.list sends page and limit together, because limit alone is silently ignored"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .list(Handle, Name, IssueQuery.Empty, window(2, 25)) .map(_ => assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "25"))) @@ -89,7 +70,7 @@ final class IssueApiSuite extends FunSuite: .withLabels(Vector(orFail(LabelName.from("bug")), orFail(LabelName.from("upstream")))) .updatedSince(Instant.parse("2026-07-01T00:00:00Z")) - onBackend(backend): api => + onApi(backend): api => api .list(Handle, Name, query, PageParams.First) .map: _ => @@ -107,7 +88,7 @@ final class IssueApiSuite extends FunSuite: test("issues.list ends where rel=next says it ends, not where a short page suggests"): val backend = responding(200, IssueApiSuite.IssueListBody, IssueApiSuite.PagedHeaders) - onStub(backend): api => + onApi(backend): api => api.list(Handle, Name, IssueQuery.Empty, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(1590)) @@ -115,13 +96,13 @@ final class IssueApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page whose response carries no Link header reports itself as the last one"): - onStub(responding(200, IssueApiSuite.IssueListBody)): api => + onApi(responding(200, IssueApiSuite.IssueListBody)): api => api.list(Handle, Name, IssueQuery.Empty, PageParams.First).map: page => assertEquals(page.isLast, true) assertEquals(page.nextPage, None) test("a page past the end is an empty page, not a failure — Forgejo answers 200 with []"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.list(Handle, Name, IssueQuery.Empty, PageParams.First).map: page => assertEquals(page.items, Vector.empty[Issue]) assertEquals(page.isLast, true) @@ -129,7 +110,7 @@ final class IssueApiSuite extends FunSuite: test("issues.comments.list targets the issue's comments and pages it"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listComments(Handle, Name, Number, PageParams.First) .map: _ => @@ -139,7 +120,7 @@ final class IssueApiSuite extends FunSuite: test("issues.labels.list targets the repository's labels, not an issue's"): val backend = RecordingBackend(responding(200, IssueApiSuite.LabelListBody)) - onBackend(backend): api => + onApi(backend): api => api.listLabels(Handle, Name, PageParams.First).map: page => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/Codeberg/Community/labels") assertEquals(page.items.map(_.name), Vector("bug")) @@ -147,7 +128,7 @@ final class IssueApiSuite extends FunSuite: test("issues.milestones.list always states which states it wants"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listMilestones(Handle, Name, StateFilter.All, PageParams.First) .map: _ => @@ -157,7 +138,7 @@ final class IssueApiSuite extends FunSuite: test("a single-milestone read addresses a milestone by id"): val backend = RecordingBackend(responding(200, IssueApiSuite.MilestoneBody)) - onBackend(backend): api => + onApi(backend): api => api.getMilestone(Handle, Name, Release).map: milestone => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/Codeberg/Community/milestones/3109") assertEquals(milestone.title, "Forgejo v1.18.0-0") @@ -169,7 +150,7 @@ final class IssueApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, IssueApiSuite.IssueBody)) val command = orFail(CreateIssue.of("Bye")).withBody("403 on every clone") - onBackend(backend): api => + onApi(backend): api => api.create(Handle, Name, command).map: issue => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/Codeberg/Community/issues") @@ -184,7 +165,7 @@ final class IssueApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.attempt .create(Handle, Name, orFail(CreateIssue.of("Bye"))) .map: outcome => @@ -199,7 +180,7 @@ final class IssueApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.get(Handle, Name, Number).map: issue => assertEquals(issue.number.value, 2966L) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -207,7 +188,7 @@ final class IssueApiSuite extends FunSuite: test("issues.edit PATCHes only what the command sets"): val backend = RecordingBackend(responding(201, IssueApiSuite.IssueBody)) - onBackend(backend): api => + onApi(backend): api => api .edit(Handle, Name, Number, EditIssue.Empty.close) .map: _ => @@ -223,7 +204,7 @@ final class IssueApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.attempt .edit(Handle, Name, Number, EditIssue.Empty.close) .map(_ => assertEquals(backend.allInteractions.size, 1, "the PATCH was retried")) @@ -231,7 +212,7 @@ final class IssueApiSuite extends FunSuite: test("issues.comments.create POSTs the comment body to the issue's comments"): val backend = RecordingBackend(responding(201, IssueApiSuite.CommentBody)) - onBackend(backend): api => + onApi(backend): api => api .createComment(Handle, Name, Number, orFail(CreateComment.of("looks right"))) .map: comment => @@ -244,7 +225,7 @@ final class IssueApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, IssueApiSuite.LabelBody)) val command = CreateLabel.of(orFail(LabelName.from("bug")), orFail(LabelColor.from("ee0701"))) - onBackend(backend): api => + onApi(backend): api => api.createLabel(Handle, Name, command).map: label => assertEquals(methodOf(backend), "POST") assertEquals(bodyOf(backend), """{"name":"bug","color":"#ee0701"}""") @@ -253,149 +234,64 @@ final class IssueApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, IssueApiSuite.NotFoundBody)): api => + onApi(responding(404, IssueApiSuite.NotFoundBody)): api => api.get(Handle, Name, Number).failed.map: case CodebergException(error) => assertEquals(summary(error), (IssueApi.GetOperation, 404, Some("GetIssueByIndex"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, IssueApiSuite.NotFoundBody)): api => + onApi(responding(404, IssueApiSuite.NotFoundBody)): api => for raised <- api.get(Handle, Name, Number).failed typed <- api.attempt.get(Handle, Name, Number) yield assertRailsAgree(raised, typed) test("a 422 on a create reaches both rails identically, carrying Forgejo's errors array"): - onStub(responding(422, IssueApiSuite.ValidationBody)): api => + onApi(responding(422, IssueApiSuite.ValidationBody)): api => for raised <- api.create(Handle, Name, orFail(CreateIssue.of("Bye"))).failed typed <- api.attempt.create(Handle, Name, orFail(CreateIssue.of("Bye"))) yield - assertEquals(details(typed), List("title is required")) + assertEquals(detailsOf(typed), List("title is required")) assertRailsAgree(raised, typed) test("a 422 carries the create operation id, so an alert can name the endpoint"): - onStub(responding(422, IssueApiSuite.ValidationBody)): api => + onApi(responding(422, IssueApiSuite.ValidationBody)): api => api.attempt.create(Handle, Name, orFail(CreateIssue.of("Bye"))).map: outcome => - assertEquals(operation(outcome), IssueApi.CreateOperation) + assertEquals(operationOf(outcome), IssueApi.CreateOperation) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, IssueApiSuite.ValidationBody)): api => + onApi(responding(400, IssueApiSuite.ValidationBody)): api => api.attempt.list(Handle, Name, IssueQuery.Empty, PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"id":1,"title":"t","state":"open"}""")): api => + onApi(responding(200, """{"id":1,"title":"t","state":"open"}""")): api => api.attempt.get(Handle, Name, Number).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.number") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a list body reports its position, all the way through the pipeline"): - onStub(responding(200, """[{"id":1,"number":1,"title":"t","state":"open"},{"id":2,"number":2,"title":"t"}]""")): + onApi(responding(200, """[{"id":1,"number":1,"title":"t","state":"open"},{"id":2,"number":2,"title":"t"}]""")): api => api.attempt.list(Handle, Name, IssueQuery.Empty, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].state") case other => fail(s"expected a decoding failure, got $other") test("both rails agree on a comment listing failure as well, so the choice of rail is only a choice of style"): - onStub(responding(404, IssueApiSuite.NotFoundBody)): api => + onApi(responding(404, IssueApiSuite.NotFoundBody)): api => for raised <- api.listComments(Handle, Name, Number, PageParams.First).failed typed <- api.attempt.listComments(Handle, Name, Number, PageParams.First) yield assertRailsAgree(raised, typed) - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def details[A](result: Either[CodebergError, A]): List[String] = - result match - case Left(CodebergError.Api(_, _, body)) => body.errors - case other => fail(s"expected an Api failure, got $other") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: IssueApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: IssueApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = IssueApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(IssueApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: IssueApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(IssueApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object IssueApiSuite: @@ -456,12 +352,3 @@ object IssueApiSuite: private val ValidationBody: String = """{"message":"CreateIssue","url":"https://codeberg.org/api/swagger","errors":["title is required"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala index bb5b4ee..51ed19e 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/issues/IssueLaneHarness.scala @@ -1,64 +1,20 @@ package com.worxbend.codeberg4s.issues -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig -import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.CodebergException +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.paging.PageNumber -import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort - -import sttp.client4.Backend -import sttp.client4.testing.BackendStub -import sttp.client4.testing.RecordingBackend -import sttp.client4.testing.ResponseStub -import sttp.model.Header -import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext -import scala.concurrent.Future -import scala.concurrent.duration.DurationInt - -/** The stub backend, the pipeline and the assertions the issue group's suites share. +/** The fixtures the issue group's suites share, on top of the module-wide [[ClientSuiteHarness]]. * - * [[IssueApiSuite]] predates this trait and keeps its own copies; nothing here changes what that suite does. Every - * suite added with the rest of the issue surface mixes this in instead, so that eight suites cannot drift into eight - * different ideas of what "the dialled path" means. - * - * '''Nothing here opens a socket.''' The subject of every suite that uses it is the wiring — which URI is dialled, - * which query parameters and which body are sent, what each rail does with a failure — never the network. + * The stub backend, the pipeline and the request assertions live in [[ClientSuiteHarness]], which every API suite in + * this module mixes in. What is left here is only what is specific to the issue surface: the repository, the issue and + * the comment every suite in the group addresses, and the `404` body they stub. */ -trait IssueLaneHarness: +trait IssueLaneHarness extends ClientSuiteHarness: self: FunSuite => - /** The execution context every suite's futures run on — munit's own, so a hung assertion fails the test rather than - * the JVM. - */ - given ExecutionContext = munitExecutionContext - - /** The effect the APIs are built with. Exposed rather than kept inside [[onPipeline]] because a suite constructs its - * own API instance at the call site, where the context parameter has to be resolvable. - */ - given Exec[Future] = FutureExec() - - /** The instance every suite pretends to talk to. */ - val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - /** The repository owner every suite uses. */ val Handle: Owner = orFail(Owner.from("Codeberg")) @@ -71,107 +27,9 @@ trait IssueLaneHarness: /** The comment every suite addresses, matching the first element of `golden/issue/comments-list.json`. */ val CommentRef: CommentId = orFail(CommentId.from(20366420L)) - /** A backend that answers every request with `status` and `body`. */ - def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - /** A backend that answers every request with `status`, `body` and `headers`. */ - def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers a `503` first and then `body`, for asserting whether a call was repeated. */ - def flakyThen(status: Int, body: String): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic( - ResponseStub.adjust("", StatusCode(503)), - ResponseStub.adjust(body, StatusCode(status)), - ) - - /** The URI the first recorded request dialled, query string and all. */ - def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - /** The query parameters of the first recorded request, in the order they were sent. */ - def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - /** The HTTP method of the first recorded request. */ - def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - /** The body of the first recorded request, as sttp renders it for display. */ - def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - /** A pagination window, for the suites that assert on `page` and `limit`. */ - def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - /** Builds the pipeline this group's APIs sit on, and releases the timer whatever the outcome. */ - def onPipeline[A](backend: Backend[Future])(use: ApiPipeline[Future] => Future[A]): Future[A] = - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = IssueLaneHarness.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(pipeline).transform: outcome => - timer.close() - outcome - - /** Asserts that the convenience rail's raised failure and the typed rail's `Left` describe the same thing. */ - def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - /** The operation id, status and message of an `Api` failure, which is what the two rails must agree on. */ - def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - /** Unwraps a smart constructor in a fixture, failing the suite rather than the call under test. */ - def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - -/** The retry policy and the error bodies the issue group's suites share. */ +/** The error body the issue group's suites share. */ object IssueLaneHarness: - /** Retries promptly and predictably: the default policy would make every retry test take a quarter of a second. */ - val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) - /** A `404` shaped like `golden/error/404-repo-not-found.json`: a Go symbol for a message, and the useful text in * `errors`. */ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApiSuite.scala index eb8e31b..6658eeb 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/miscellaneous/MiscellaneousApiSuite.scala @@ -1,22 +1,9 @@ package com.worxbend.codeberg4s.miscellaneous -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec import com.worxbend.codeberg4s.codec.Json -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.JitterSource -import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.testing.BackendStub @@ -26,9 +13,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** The instance-level group over a `BackendStub`: nothing here opens a socket. * @@ -36,21 +21,10 @@ import scala.concurrent.duration.DurationInt * whether the two rails agree about a failure. Decoding itself is asserted in `modules/codec` against the golden * captures, so the payloads below are the smallest bodies that exercise a seam. */ -final class MiscellaneousApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext - - private given JitterSource = JitterSource.Deterministic - - /** The API root every assertion about a dialled URI is written against. */ - private val Root: String = "https://forge.example/api/v1" - - private val Config: CodebergConfig = - CodebergConfig(Auth.Anonymous) - .copy(baseUri = orFail(BaseUri.from(Root)), retry = MiscellaneousApiSuite.PromptRetry) +final class MiscellaneousApiSuite extends FunSuite with ClientSuiteHarness: test("apiSettings reports the clamp that makes items.size an unusable end-of-pages test"): - onBackend(responding(200, MiscellaneousApiSuite.ApiSettingsBody)): api => + onApi(responding(200, MiscellaneousApiSuite.ApiSettingsBody)): api => api.apiSettings().map: settings => assertEquals(settings.maxResponseItems, 50L) assertEquals(settings.defaultPagingNum, 30L) @@ -59,13 +33,13 @@ final class MiscellaneousApiSuite extends FunSuite: test("apiSettings targets /settings/api on the configured instance"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.ApiSettingsBody)) - onBackend(backend): api => + onApi(backend): api => api.apiSettings().map(_ => assertEquals(dialled(backend), s"$Root/settings/api")) test("repositorySettings targets /settings/repository and reports the disabled features"): val backend = RecordingBackend(responding(200, """{"forks_disabled":true}""")) - onBackend(backend): api => + onApi(backend): api => api.repositorySettings().map: settings => assertEquals(dialled(backend), s"$Root/settings/repository") assertEquals(settings.forksDisabled, true) @@ -74,7 +48,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("attachmentSettings targets /settings/attachment and splits the allowed types"): val backend = RecordingBackend(responding(200, """{"enabled":true,"allowed_types":"image/png,.pdf"}""")) - onBackend(backend): api => + onApi(backend): api => api.attachmentSettings().map: settings => assertEquals(dialled(backend), s"$Root/settings/attachment") assertEquals(settings.allowedTypes, Vector("image/png", ".pdf")) @@ -82,27 +56,27 @@ final class MiscellaneousApiSuite extends FunSuite: test("signingKey hands back an armored block that no JSON parser would have accepted"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.ArmoredKey)) - onBackend(backend): api => + onApi(backend): api => api.signingKey().map: key => assertEquals(dialled(backend), s"$Root/signing-key.gpg") assertEquals(key, Some(SigningKey(MiscellaneousApiSuite.ArmoredKey))) test("an instance that signs nothing answers 200 with an empty body, which is None and not a failure"): - onBackend(responding(200, "")): api => + onApi(responding(200, "")): api => api.signingKey().map(key => assertEquals(key, None)) test("renderMarkdown POSTs the capitalised option body to /markdown"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.RenderedHtml)) - onBackend(backend): api => + onApi(backend): api => api.renderMarkdown(MarkdownRenderRequest.of("# Title")).map: _ => assertEquals(dialled(backend), s"$Root/markdown") - assertEquals(method(backend), "POST") + assertEquals(methodOf(backend), "POST") assertEquals(Json.parse(sentBody(backend)).toOption.flatMap(_.field("Text")).flatMap(_.strOpt), Some("# Title")) - assertEquals(contentType(backend), Some("application/json")) + assertEquals(contentTypeOf(backend), Some("application/json")) test("renderMarkdown returns the HTML fragment verbatim, unparsed"): - onBackend(responding(200, MiscellaneousApiSuite.RenderedHtml)): api => + onApi(responding(200, MiscellaneousApiSuite.RenderedHtml)): api => api .renderMarkdown(MarkdownRenderRequest.of("# Title")) .map(html => assertEquals(html, RenderedMarkdown(MiscellaneousApiSuite.RenderedHtml))) @@ -110,11 +84,11 @@ final class MiscellaneousApiSuite extends FunSuite: test("renderMarkdownRaw sends the markdown itself as a plain-text body"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.RenderedHtml)) - onBackend(backend): api => + onApi(backend): api => api.renderMarkdownRaw("# Title").map: html => assertEquals(dialled(backend), s"$Root/markdown/raw") assertEquals(sentBody(backend), "# Title") - assertEquals(contentType(backend), Some("text/plain; charset=utf-8")) + assertEquals(contentTypeOf(backend), Some("text/plain; charset=utf-8")) assertEquals(html, RenderedMarkdown(MiscellaneousApiSuite.RenderedHtml)) test("rendering is retried after a 503 even though it is a POST, because it changes nothing"): @@ -125,26 +99,26 @@ final class MiscellaneousApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.renderMarkdown(MarkdownRenderRequest.of("# Title")).map: html => assertEquals(html, RenderedMarkdown(MiscellaneousApiSuite.RenderedHtml)) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onBackend(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => + onApi(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => api.apiSettings().failed.map: case CodebergException(error) => assertEquals(summary(error), MiscellaneousApiSuite.ExpectedNotFound) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onBackend(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => + onApi(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => for raised <- api.apiSettings().failed typed <- api.attempt.apiSettings() - yield assertRailsAgree(raised, typed) + yield assertNotFoundOnBothRails(raised, typed) test("a 422 on a rejected render reaches both rails as the same Api failure"): - onBackend(responding(422, MiscellaneousApiSuite.ValidationBody)): api => + onApi(responding(422, MiscellaneousApiSuite.ValidationBody)): api => for raised <- api.renderMarkdown(MarkdownRenderRequest.of("# Title")).failed typed <- api.attempt.renderMarkdown(MarkdownRenderRequest.of("# Title")) @@ -156,7 +130,7 @@ final class MiscellaneousApiSuite extends FunSuite: fail(s"the rails disagreed: $convenience versus $materialised") test("a 200 whose settings payload names no limits becomes DecodingFailed, never an exception"): - onBackend(responding(200, """{"unexpected":true}""")): api => + onApi(responding(200, """{"unexpected":true}""")): api => api.attempt.apiSettings().map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.max_response_items") case other => fail(s"expected a decoding failure, got $other") @@ -166,7 +140,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("uiSettings targets /settings/ui and reports what the reaction endpoints will accept"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.UiSettingsBody)) - onBackend(backend): api => + onApi(backend): api => api.uiSettings().map: settings => assertEquals(dialled(backend), s"$Root/settings/ui") assertEquals(settings.allowedReactions, Vector("+1", "heart")) @@ -176,7 +150,7 @@ final class MiscellaneousApiSuite extends FunSuite: assertEquals(settings.allows("rocket"), false) test("an instance that reports no UI settings at all still decodes, because none of them is required"): - onBackend(responding(200, "{}")): api => + onApi(responding(200, "{}")): api => api.uiSettings().map: settings => assertEquals(settings.allowedReactions, Vector.empty[String]) assertEquals(settings.defaultTheme, None) @@ -187,17 +161,17 @@ final class MiscellaneousApiSuite extends FunSuite: test("sshSigningKey hands back an authorized-key line that no JSON parser would have accepted"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.OpenSshKey)) - onBackend(backend): api => + onApi(backend): api => api.sshSigningKey().map: key => assertEquals(dialled(backend), s"$Root/signing-key.ssh") assertEquals(key, Some(SshSigningKey(MiscellaneousApiSuite.OpenSshKey))) test("an instance that does not sign with SSH answers 200 with an empty body, which is None and not a failure"): - onBackend(responding(200, "")): api => + onApi(responding(200, "")): api => api.sshSigningKey().map(key => assertEquals(key, None)) test("the 404 this endpoint declares is a failure, unlike the empty body"): - onBackend(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => + onApi(responding(404, MiscellaneousApiSuite.NotFoundBody)): api => api.attempt.sshSigningKey().map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 404) case other => fail(s"expected an Api failure, got $other") @@ -207,13 +181,13 @@ final class MiscellaneousApiSuite extends FunSuite: test("gitignoreTemplates targets /gitignore/templates and returns names that address the by-name endpoint"): val backend = RecordingBackend(responding(200, """["AL","Actionscript","Ada"]""")) - onBackend(backend): api => + onApi(backend): api => api.gitignoreTemplates().map: names => assertEquals(dialled(backend), s"$Root/gitignore/templates") assertEquals(names.map(_.value), Vector("AL", "Actionscript", "Ada")) test("a listing carrying a name nothing could be addressed with fails at that element's own index"): - onBackend(responding(200, """["AL","..","Ada"]""")): api => + onApi(responding(200, """["AL","..","Ada"]""")): api => api.attempt.gitignoreTemplates().map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1]") case other => fail(s"expected a decoding failure, got $other") @@ -221,7 +195,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("gitignoreTemplate percent-encodes a template name that carries a space"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.GitignoreTemplateBody)) - onBackend(backend): api => + onApi(backend): api => api.gitignoreTemplate(orFail(TemplateName.from("Visual Studio"))).map: template => assertEquals(dialled(backend), s"$Root/gitignore/templates/Visual%20Studio") assertEquals(template.name, Some("Visual Studio")) @@ -230,7 +204,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("labelTemplates targets the singular /label/templates"): val backend = RecordingBackend(responding(200, """["Default","Advanced"]""")) - onBackend(backend): api => + onApi(backend): api => api.labelTemplates().map: names => assertEquals(dialled(backend), s"$Root/label/templates") assertEquals(names.map(_.value), Vector("Default", "Advanced")) @@ -238,7 +212,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("labelTemplate answers every label in the set, and a colour it cannot read is None rather than a failure"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.LabelTemplateBody)) - onBackend(backend): api => + onApi(backend): api => api.labelTemplate(orFail(TemplateName.from("Default"))).map: labels => assertEquals(dialled(backend), s"$Root/label/templates/Default") assertEquals(labels.map(_.name), Vector("bug", "duplicate")) @@ -249,7 +223,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("licenseTemplates targets /licenses and carries no license text"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.LicenseListBody)) - onBackend(backend): api => + onApi(backend): api => api.licenseTemplates().map: entries => assertEquals(dialled(backend), s"$Root/licenses") assertEquals(entries.map(_.name.value), Vector("MIT", "GNU Affero General Public License v3.0")) @@ -258,7 +232,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("licenseTemplate fetches one body, percent-encoding the spaces a license name genuinely has"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.LicenseBody)) - onBackend(backend): api => + onApi(backend): api => api.licenseTemplate(orFail(TemplateName.from("GNU Affero General Public License v3.0"))).map: template => assertEquals( dialled(backend), @@ -271,23 +245,23 @@ final class MiscellaneousApiSuite extends FunSuite: test("renderMarkup POSTs the capitalised option body to /markup, file path included"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.RenderedHtml)) - onBackend(backend): api => + onApi(backend): api => api.renderMarkup(MarkupRenderRequest.ofFile("* Title", "README.org")).map: html => assertEquals(dialled(backend), s"$Root/markup") - assertEquals(method(backend), "POST") + assertEquals(methodOf(backend), "POST") assertEquals(Json.parse(sentBody(backend)).toOption.flatMap(_.field("Text")).flatMap(_.strOpt), Some("* Title")) assertEquals(Json.parse(sentBody(backend)).toOption.flatMap(_.field("Mode")).flatMap(_.strOpt), Some("file")) assertEquals( Json.parse(sentBody(backend)).toOption.flatMap(_.field("FilePath")).flatMap(_.strOpt), Some("README.org"), ) - assertEquals(contentType(backend), Some("application/json")) + assertEquals(contentTypeOf(backend), Some("application/json")) assertEquals(html, RenderedMarkdown(MiscellaneousApiSuite.RenderedHtml)) test("renderMarkup omits the three optional keys the caller did not set"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.RenderedHtml)) - onBackend(backend): api => + onApi(backend): api => api.renderMarkup(MarkupRenderRequest.of("# Title", MarkupMode.Gfm)).map: _ => assertEquals(Json.parse(sentBody(backend)).toOption.map(_.keys.toList.sorted), Some(List("Mode", "Text", "Wiki"))) @@ -299,7 +273,7 @@ final class MiscellaneousApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.renderMarkup(MarkupRenderRequest.of("# Title", MarkupMode.Markdown)).map: _ => assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -308,7 +282,7 @@ final class MiscellaneousApiSuite extends FunSuite: test("nodeInfo targets /nodeinfo and reads the federation document rather than handing back a string"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.NodeInfoBody)) - onBackend(backend): api => + onApi(backend): api => api.nodeInfo().map: info => assertEquals(dialled(backend), s"$Root/nodeinfo") assertEquals(info.version, "2.1") @@ -318,7 +292,7 @@ final class MiscellaneousApiSuite extends FunSuite: assertEquals(info.usage.flatMap(_.users).flatMap(_.total), Some(1234L)) test("a nodeinfo document naming no software fails at $.software, on both rails alike"): - onBackend(responding(200, """{"version":"2.1"}""")): api => + onApi(responding(200, """{"version":"2.1"}""")): api => for raised <- api.nodeInfo().failed typed <- api.attempt.nodeInfo() @@ -334,14 +308,14 @@ final class MiscellaneousApiSuite extends FunSuite: test("actionsRun targets /actions/run and decodes the run the calling token belongs to"): val backend = RecordingBackend(responding(200, MiscellaneousApiSuite.ActionRunBody)) - onBackend(backend): api => + onApi(backend): api => api.actionsRun().map: run => assertEquals(dialled(backend), s"$Root/actions/run") assertEquals(run.id.value, 4711L) assertEquals(run.indexInRepo, Some(42L)) test("a 401 from the workflow-run lookup reaches both rails as the same Api failure"): - onBackend(responding(401, MiscellaneousApiSuite.UnauthorizedBody)): api => + onApi(responding(401, MiscellaneousApiSuite.UnauthorizedBody)): api => for raised <- api.actionsRun().failed typed <- api.attempt.actionsRun() @@ -355,68 +329,26 @@ final class MiscellaneousApiSuite extends FunSuite: // --- assertions ----------------------------------------------------------- - /** Both rails must report the same failure, so the choice between them is a choice of style and nothing else. */ - private def assertRailsAgree(raised: Throwable, typed: Either[CodebergError, ServerApiSettings]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - assertEquals(summary(materialised), MiscellaneousApiSuite.ExpectedNotFound) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - /** An `Api` failure projected onto the parts that do not depend on wall-clock time, so two calls are comparable. */ - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") + /** Both rails must report the same `404`, so the choice between them is a choice of style and nothing else. + * + * [[ClientSuiteHarness.assertRailsAgree]] settles that the two agree; what this adds is which failure they agreed + * on, so a pair of rails that agreed on the wrong one would still fail the test. + */ + private def assertNotFoundOnBothRails(raised: Throwable, typed: Either[CodebergError, ServerApiSettings]): Unit = + assertRailsAgree(raised, typed) + assertEquals(summary(typed.swap.getOrElse(fail("expected a failure"))), MiscellaneousApiSuite.ExpectedNotFound) // --- fixtures ------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status))) - - private def recorded(backend: RecordingBackend): sttp.client4.GenericRequest[?, ?] = - backend.allInteractions.headOption match - case Some((request, _)) => request - case None => fail("no request reached the backend") - - private def dialled(backend: RecordingBackend): String = - recorded(backend).uri.toString - - private def method(backend: RecordingBackend): String = - recorded(backend).method.method + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: MiscellaneousApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(MiscellaneousApi(pipeline))) private def sentBody(backend: RecordingBackend): String = - recorded(backend).body match + firstRequest(backend).body match case sttp.client4.StringBody(value, _, _) => value case other => fail(s"expected a string body, got $other") - private def contentType(backend: RecordingBackend): Option[String] = - recorded(backend).contentType - - /** Runs `use` against an anonymous client on `backend`, releasing the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: MiscellaneousApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, Config), - Config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(MiscellaneousApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object MiscellaneousApiSuite: @@ -493,12 +425,3 @@ object MiscellaneousApiSuite: private val ExpectedValidation: (String, Int, Option[String]) = (MiscellaneousApi.RenderMarkdownOperation, 422, Some("Unsupported render mode")) - - /** Retries promptly and predictably: the default policy would make the retry test take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala index 2fd9015..467474c 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/notifications/NotificationApiSuite.scala @@ -1,25 +1,12 @@ package com.worxbend.codeberg4s.notifications -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.testing.BackendStub @@ -30,9 +17,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt import java.time.Instant @@ -46,9 +31,7 @@ import java.time.Instant * Every body in this file is invented, as is every body this group has: `GET /notifications` answers `401` without a * token, so no capture exists. See [[NotificationThread]]. */ -final class NotificationApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class NotificationApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("Codeberg")) @@ -56,14 +39,12 @@ final class NotificationApiSuite extends FunSuite: private val Thread: NotificationThreadId = orFail(NotificationThreadId.from(4821L)) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - // --- reads ---------------------------------------------------------------- test("notifications.list targets /notifications on the configured instance"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .list(NotificationQuery.Empty, PageParams.First) .map(_ => assertEquals(pathOf(backend), "https://forge.example/api/v1/notifications")) @@ -71,7 +52,7 @@ final class NotificationApiSuite extends FunSuite: test("notifications.list sends page and limit together, because limit alone is silently ignored"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .list(NotificationQuery.Empty, window(2, 25)) .map(_ => assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "25"))) @@ -83,7 +64,7 @@ final class NotificationApiSuite extends FunSuite: .withSubjects(Vector(NotificationSubjectFilter.Pull)) .updatedSince(Instant.parse("2026-07-01T00:00:00Z")) - onBackend(backend): api => + onApi(backend): api => api .list(query, PageParams.First) .map: _ => @@ -101,7 +82,7 @@ final class NotificationApiSuite extends FunSuite: ) test("notifications.list maps the instance's payload to domain threads"): - onStub(responding(200, NotificationApiSuite.ThreadListBody)): api => + onApi(responding(200, NotificationApiSuite.ThreadListBody)): api => api.list(NotificationQuery.Empty, PageParams.First).map: page => assertEquals(page.size, 1) assertEquals(page.items.head.id.value, 4821L) @@ -109,27 +90,27 @@ final class NotificationApiSuite extends FunSuite: assertEquals(page.items.head.subject.map(_.subjectType), Some(NotificationSubjectType.Issue)) test("notifications.list ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, NotificationApiSuite.ThreadListBody, NotificationApiSuite.PagedHeaders)): api => + onApi(responding(200, NotificationApiSuite.ThreadListBody, NotificationApiSuite.PagedHeaders)): api => api.list(NotificationQuery.Empty, window(1, 30)).map: page => assertEquals(page.totalCount, Some(74)) assertEquals(page.nextPage.map(_.value), Some(2)) assertEquals(page.isLast, false) test("a page whose response carries no Link header reports itself as the last one, whatever the total says"): - onStub(responding(200, NotificationApiSuite.ThreadListBody, List(Header("X-Total-Count", "74")))): api => + onApi(responding(200, NotificationApiSuite.ThreadListBody, List(Header("X-Total-Count", "74")))): api => api.list(NotificationQuery.Empty, PageParams.First).map: page => assertEquals(page.totalCount, Some(74)) assertEquals(page.isLast, true) assertEquals(page.nextPage, None) test("a page past the end is an empty page, not a failure — Forgejo answers 200 with []"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.list(NotificationQuery.Empty, PageParams.First).map: page => assertEquals(page.items, Vector.empty[NotificationThread]) assertEquals(page.isLast, true) test("notifications.new reads the count, not a boolean"): - onStub(responding(200, """{"new":17}""")): api => + onApi(responding(200, """{"new":17}""")): api => api.unreadCount().map: unread => assertEquals(unread.value, 17L) assertEquals(unread.hasUnread, true) @@ -137,7 +118,7 @@ final class NotificationApiSuite extends FunSuite: test("notifications.new targets /notifications/new and sends no parameters"): val backend = RecordingBackend(responding(200, """{"new":0}""")) - onBackend(backend): api => + onApi(backend): api => api .unreadCount() .map: _ => @@ -147,7 +128,7 @@ final class NotificationApiSuite extends FunSuite: test("a single-thread read addresses a thread by id"): val backend = RecordingBackend(responding(200, NotificationApiSuite.ThreadBody)) - onBackend(backend): api => + onApi(backend): api => api.getThread(Thread).map: thread => assertEquals(pathOf(backend), "https://forge.example/api/v1/notifications/threads/4821") assertEquals(thread.id.value, 4821L) @@ -156,7 +137,7 @@ final class NotificationApiSuite extends FunSuite: test("notifications.repository.list targets the repository's notifications, not the repository's issues"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listRepository(Handle, Name, NotificationQuery.Empty, PageParams.First) .map: _ => @@ -168,7 +149,7 @@ final class NotificationApiSuite extends FunSuite: test("notifications.read PUTs to /notifications with no parameters and no body"): val backend = RecordingBackend(responding(205, "")) - onBackend(backend): api => + onApi(backend): api => api .markAllRead() .map: _ => @@ -178,17 +159,17 @@ final class NotificationApiSuite extends FunSuite: assertEquals(bodyOf(backend), "empty") test("a 205 with an empty body is a success, since the mark-read body is deliberately ignored"): - onStub(responding(205, "")): api => + onApi(responding(205, "")): api => api.attempt.markAllRead().map(outcome => assertEquals(outcome, Right(()))) test("a 205 that does carry the changed threads is a success too, and still decodes nothing"): - onStub(responding(205, NotificationApiSuite.ThreadListBody)): api => + onApi(responding(205, NotificationApiSuite.ThreadListBody)): api => api.attempt.markAllRead().map(outcome => assertEquals(outcome, Right(()))) test("notifications.threads.read PATCHes the thread and sends no to-status"): val backend = RecordingBackend(responding(205, "")) - onBackend(backend): api => + onApi(backend): api => api .markThreadRead(Thread) .map: _ => @@ -199,7 +180,7 @@ final class NotificationApiSuite extends FunSuite: test("notifications.repository.read PUTs to the repository's notifications"): val backend = RecordingBackend(responding(205, "")) - onBackend(backend): api => + onApi(backend): api => api .markRepositoryRead(Handle, Name) .map: _ => @@ -216,7 +197,7 @@ final class NotificationApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api .markAllRead() .map(_ => assertEquals(backend.allInteractions.size, 2, "the PUT was not retried")) @@ -229,7 +210,7 @@ final class NotificationApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api .markThreadRead(Thread) .map(_ => assertEquals(backend.allInteractions.size, 2, "the PATCH was not retried")) @@ -242,7 +223,7 @@ final class NotificationApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.getThread(Thread).map: thread => assertEquals(thread.id.value, 4821L) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -250,154 +231,74 @@ final class NotificationApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 401 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(401, NotificationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, NotificationApiSuite.UnauthorizedBody)): api => api.list(NotificationQuery.Empty, PageParams.First).failed.map: case CodebergException(error) => assertEquals(summary(error), (NotificationApi.ListOperation, 401, Some("token is required"))) case other => fail(s"expected a CodebergException, got $other") test("a 401 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(401, NotificationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, NotificationApiSuite.UnauthorizedBody)): api => for raised <- api.list(NotificationQuery.Empty, PageParams.First).failed typed <- api.attempt.list(NotificationQuery.Empty, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a single-thread failure as well, so the choice of rail is only a choice of style"): - onStub(responding(404, NotificationApiSuite.NotFoundBody)): api => + onApi(responding(404, NotificationApiSuite.NotFoundBody)): api => for raised <- api.getThread(Thread).failed typed <- api.attempt.getThread(Thread) yield assertRailsAgree(raised, typed) test("both rails agree on a mark-read failure, which is the rail a Unit-returning call is easiest to lose"): - onStub(responding(403, NotificationApiSuite.ForbiddenBody)): api => + onApi(responding(403, NotificationApiSuite.ForbiddenBody)): api => for raised <- api.markThreadRead(Thread).failed typed <- api.attempt.markThreadRead(Thread) yield assertRailsAgree(raised, typed) test("both rails agree on a repository-listing failure"): - onStub(responding(404, NotificationApiSuite.NotFoundBody)): api => + onApi(responding(404, NotificationApiSuite.NotFoundBody)): api => for raised <- api.listRepository(Handle, Name, NotificationQuery.Empty, PageParams.First).failed typed <- api.attempt.listRepository(Handle, Name, NotificationQuery.Empty, PageParams.First) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, NotificationApiSuite.ValidationBody)): api => + onApi(responding(400, NotificationApiSuite.ValidationBody)): api => api.attempt.list(NotificationQuery.Empty, PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 401 carries the operation id, so an alert can name the endpoint"): - onStub(responding(401, NotificationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, NotificationApiSuite.UnauthorizedBody)): api => api.attempt.unreadCount().map: outcome => - assertEquals(operation(outcome), NotificationApi.UnreadCountOperation) + assertEquals(operationOf(outcome), NotificationApi.UnreadCountOperation) test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"unread":true}""")): api => + onApi(responding(200, """{"unread":true}""")): api => api.attempt.getThread(Thread).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a list body reports its position, all the way through the pipeline"): - onStub(responding(200, """[{"id":1},{"subject":{"title":"t"}}]""")): api => + onApi(responding(200, """[{"id":1},{"subject":{"title":"t"}}]""")): api => api.attempt.list(NotificationQuery.Empty, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].id") case other => fail(s"expected a decoding failure, got $other") test("a count body without the new key is where 'the spec was wrong' shows up"): - onStub(responding(200, """{}""")): api => + onApi(responding(200, """{}""")): api => api.attempt.unreadCount().map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.new") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: NotificationApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: NotificationApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = NotificationApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(NotificationApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: NotificationApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(NotificationApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * @@ -451,12 +352,3 @@ object NotificationApiSuite: private val ValidationBody: String = """{"message":"parsing time \"notadate\"","url":"https://codeberg.org/api/swagger"}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala index 0ebcc03..87fcf59 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationAdminApiSuite.scala @@ -45,7 +45,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.attempt .create(CreateOrganization.named(Org)) - .map(_ => assertEquals(callCount(backend), 1, "the create was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the create was repeated")) test("orgs.edit patches the organisation and never sends a name"): val backend = RecordingBackend(responding(200, OrganizationAdminApiSuite.OrgBody)) @@ -64,7 +64,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.attempt .edit(Org, EditOrganization.Empty.describedAs("we forge")) - .map(_ => assertEquals(callCount(backend), 1, "the edit was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the edit was repeated")) test("orgs.delete sends a bodiless DELETE"): val backend = RecordingBackend(responding(204, "")) @@ -81,7 +81,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => - api.attempt.delete(Org).map(_ => assertEquals(callCount(backend), 1, "the delete was repeated")) + api.attempt.delete(Org).map(_ => assertEquals(attemptsOn(backend), 1, "the delete was repeated")) // --- rename --------------------------------------------------------------- @@ -102,7 +102,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.attempt .rename(Org, orFail(OrgName.from("forgejo-forge"))) - .map(_ => assertEquals(callCount(backend), 1, "the rename was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the rename was repeated")) // --- avatar --------------------------------------------------------------- @@ -126,7 +126,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: .map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), "https://forge.example/api/v1/orgs/forgejo/avatar") - assertEquals(callCount(backend), 1, "the avatar delete was repeated") + assertEquals(attemptsOn(backend), 1, "the avatar delete was repeated") // --- repositories --------------------------------------------------------- @@ -237,7 +237,7 @@ final class OrganizationAdminApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.attempt .publicizeMember(Org, Account) - .map(_ => assertEquals(callCount(backend), 1, "a membership write was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "a membership write was repeated")) // --- blocks --------------------------------------------------------------- diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationApiSuite.scala index 4ac66bd..90a540b 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationApiSuite.scala @@ -1,23 +1,10 @@ package com.worxbend.codeberg4s.organizations -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import com.worxbend.codeberg4s.users.UserVisibility import com.worxbend.codeberg4s.users.Username @@ -30,9 +17,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[OrganizationApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -40,9 +25,7 @@ import scala.concurrent.duration.DurationInt * failure, and what the paging headers are allowed to decide. Decoding itself is asserted against the golden captures * in `modules/codec`, so the payloads here are small hand-written bodies chosen to exercise a seam. */ -final class OrganizationApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class OrganizationApiSuite extends FunSuite with ClientSuiteHarness: private val Org: OrgName = orFail(OrgName.from("forgejo")) @@ -50,12 +33,10 @@ final class OrganizationApiSuite extends FunSuite: private val Account: Username = orFail(Username.from("earl-warren")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - // --- single reads --------------------------------------------------------- test("a single-organisation read maps the instance's payload to a domain organisation"): - onStub(responding(200, OrganizationApiSuite.OrgBody)): api => + onApi(responding(200, OrganizationApiSuite.OrgBody)): api => api.get(Org).map: organization => assertEquals(organization.id, 70422L) assertEquals(organization.name.value, "forgejo") @@ -65,13 +46,13 @@ final class OrganizationApiSuite extends FunSuite: test("a single-organisation read targets /orgs/{org} on the configured instance"): val backend = RecordingBackend(responding(200, OrganizationApiSuite.OrgBody)) - onBackend(backend): api => + onApi(backend): api => api.get(Org).map(_ => assertEquals(dialled(backend), "https://forge.example/api/v1/orgs/forgejo")) test("a single-team read is rooted at /teams/{id}, not below the organisation"): val backend = RecordingBackend(responding(200, OrganizationApiSuite.TeamBody)) - onBackend(backend): api => + onApi(backend): api => api.getTeam(Maintainers).map: team => assertEquals(dialled(backend), "https://forge.example/api/v1/teams/42") assertEquals(team.id.value, 42L) @@ -82,7 +63,7 @@ final class OrganizationApiSuite extends FunSuite: test("orgs.list sends page and limit together, because limit alone is silently ignored"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .list(window(2, 25)) .map: _ => @@ -92,7 +73,7 @@ final class OrganizationApiSuite extends FunSuite: test("orgs.repos.list targets the organisation's repositories and pages them"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .repositories(Org, PageParams.First) .map: _ => @@ -104,8 +85,8 @@ final class OrganizationApiSuite extends FunSuite: val publicMembers = RecordingBackend(responding(200, "[]")) for - _ <- onBackend(members)(api => api.members(Org, PageParams.First)) - _ <- onBackend(publicMembers)(api => api.publicMembers(Org, PageParams.First)) + _ <- onApi(members)(api => api.members(Org, PageParams.First)) + _ <- onApi(publicMembers)(api => api.publicMembers(Org, PageParams.First)) yield assertEquals(pathOf(members), "https://forge.example/api/v1/orgs/forgejo/members") assertEquals(pathOf(publicMembers), "https://forge.example/api/v1/orgs/forgejo/public_members") @@ -113,7 +94,7 @@ final class OrganizationApiSuite extends FunSuite: test("orgs.teams.list targets the organisation's teams"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .teams(Org, PageParams.First) .map: _ => @@ -125,8 +106,8 @@ final class OrganizationApiSuite extends FunSuite: val repositories = RecordingBackend(responding(200, "[]")) for - _ <- onBackend(members)(api => api.teamMembers(Maintainers, PageParams.First)) - _ <- onBackend(repositories)(api => api.teamRepositories(Maintainers, PageParams.First)) + _ <- onApi(members)(api => api.teamMembers(Maintainers, PageParams.First)) + _ <- onApi(repositories)(api => api.teamRepositories(Maintainers, PageParams.First)) yield assertEquals(pathOf(members), "https://forge.example/api/v1/teams/42/members") assertEquals(pathOf(repositories), "https://forge.example/api/v1/teams/42/repos") @@ -134,7 +115,7 @@ final class OrganizationApiSuite extends FunSuite: test("orgs.userOrgs.list names a person, so it sits under /users/{username}/orgs"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .userOrganizations(Account, window(3, 10)) .map: _ => @@ -144,23 +125,23 @@ final class OrganizationApiSuite extends FunSuite: // --- payloads and paging -------------------------------------------------- test("orgs.list decodes a bare array, because this group meets no search envelope"): - onStub(responding(200, OrganizationApiSuite.OrgListBody)): api => + onApi(responding(200, OrganizationApiSuite.OrgListBody)): api => api.list(PageParams.First).map: page => assertEquals(page.items.map(_.name.value), Vector("forgejo")) test("orgs.members.list yields the User model wave 1 owns, not a second membership type"): - onStub(responding(200, OrganizationApiSuite.MemberListBody)): api => + onApi(responding(200, OrganizationApiSuite.MemberListBody)): api => api.members(Org, PageParams.First).map: page => assertEquals(page.items.map(_.login), Vector("earl-warren")) assertEquals(page.items.map(_.id), Vector(73579L)) test("orgs.repos.list yields the Repository model wave 2 owns"): - onStub(responding(200, OrganizationApiSuite.RepoListBody)): api => + onApi(responding(200, OrganizationApiSuite.RepoListBody)): api => api.repositories(Org, PageParams.First).map: page => assertEquals(page.items.map(_.slug.value), Vector("forgejo/forgejo")) test("orgs.list ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, OrganizationApiSuite.OrgListBody, OrganizationApiSuite.PagedHeaders)): api => + onApi(responding(200, OrganizationApiSuite.OrgListBody, OrganizationApiSuite.PagedHeaders)): api => api.list(window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(24159)) @@ -168,13 +149,13 @@ final class OrganizationApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page whose response carries no Link header reports itself as the last one"): - onStub(responding(200, OrganizationApiSuite.OrgListBody)): api => + onApi(responding(200, OrganizationApiSuite.OrgListBody)): api => api.list(PageParams.First).map: page => assertEquals(page.isLast, true) assertEquals(page.nextPage, None) test("a page past the end is an empty page, not a failure — Forgejo answers 200 with []"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.teams(Org, PageParams.First).map: page => assertEquals(page.items, Vector.empty[Team]) assertEquals(page.isLast, true) @@ -189,7 +170,7 @@ final class OrganizationApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.get(Org).map: organization => assertEquals(organization.name.value, "forgejo") assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -197,157 +178,82 @@ final class OrganizationApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 401 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => api.teams(Org, PageParams.First).failed.map: case CodebergException(error) => assertEquals(summary(error), (OrganizationApi.TeamsOperation, 401, Some("token is required"))) case other => fail(s"expected a CodebergException, got $other") test("the 401 golden/MANIFEST.md records for an anonymous team listing reaches both rails identically"): - onStub(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => for raised <- api.teams(Org, PageParams.First).failed typed <- api.attempt.teams(Org, PageParams.First) yield assertRailsAgree(raised, typed) test("the 401 an anonymous /users/{username}/orgs answers reaches both rails identically"): - onStub(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => + onApi(responding(401, OrganizationApiSuite.UnauthorizedBody)): api => for raised <- api.userOrganizations(Account, PageParams.First).failed typed <- api.attempt.userOrganizations(Account, PageParams.First) yield - assertEquals(operation(typed), OrganizationApi.UserOrganizationsOperation) + assertEquals(operationOf(typed), OrganizationApi.UserOrganizationsOperation) assertRailsAgree(raised, typed) test("a 404 on a single-organisation read reaches both rails identically"): - onStub(responding(404, OrganizationApiSuite.NotFoundBody)): api => + onApi(responding(404, OrganizationApiSuite.NotFoundBody)): api => for raised <- api.get(Org).failed typed <- api.attempt.get(Org) yield - assertEquals(details(typed), List("organization does not exist [name: forgejo]")) + assertEquals(detailsOf(typed), List("organization does not exist [name: forgejo]")) assertRailsAgree(raised, typed) test("a 404 on a single-team read reaches both rails identically"): - onStub(responding(404, OrganizationApiSuite.NotFoundBody)): api => + onApi(responding(404, OrganizationApiSuite.NotFoundBody)): api => for raised <- api.getTeam(Maintainers).failed typed <- api.attempt.getTeam(Maintainers) yield - assertEquals(operation(typed), OrganizationApi.GetTeamOperation) + assertEquals(operationOf(typed), OrganizationApi.GetTeamOperation) assertRailsAgree(raised, typed) test("a 403 on a member listing reaches both rails identically"): - onStub(responding(403, OrganizationApiSuite.UnauthorizedBody)): api => + onApi(responding(403, OrganizationApiSuite.UnauthorizedBody)): api => for raised <- api.members(Org, PageParams.First).failed typed <- api.attempt.members(Org, PageParams.First) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, OrganizationApiSuite.NotFoundBody)): api => + onApi(responding(400, OrganizationApiSuite.NotFoundBody)): api => api.attempt.list(PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"name":"forgejo"}""")): api => + onApi(responding(200, """{"name":"forgejo"}""")): api => api.attempt.get(Org).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a list body reports its position, all the way through the pipeline"): - onStub(responding(200, """[{"id":1,"name":"a"},{"id":2}]""")): api => + onApi(responding(200, """[{"id":1,"name":"a"},{"id":2}]""")): api => api.attempt.list(PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].name") case other => fail(s"expected a decoding failure, got $other") test("an organisation name that could forge a path fails decoding rather than reaching the domain"): - onStub(responding(200, """{"id":1,"name":"forgejo/teams"}""")): api => + onApi(responding(200, """{"id":1,"name":"forgejo/teams"}""")): api => api.attempt.get(Org).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.name") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def details[A](result: Either[CodebergError, A]): List[String] = - result match - case Left(CodebergError.Api(_, _, body)) => body.errors - case other => fail(s"expected an Api failure, got $other") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: OrganizationApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: OrganizationApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = OrganizationApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(OrganizationApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: OrganizationApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(OrganizationApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object OrganizationApiSuite: @@ -408,12 +314,3 @@ object OrganizationApiSuite: private val NotFoundBody: String = """{"message":"GetOrgByName","url":"https://codeberg.org/api/swagger", |"errors":["organization does not exist [name: forgejo]"]}""".stripMargin - - /** Retries promptly and predictably: the default policy would make the retry test take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationHookApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationHookApiSuite.scala index 18f6430..d7c88c0 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationHookApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationHookApiSuite.scala @@ -100,7 +100,7 @@ final class OrganizationHookApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.hooks.attempt .create(Org, CreateHook.to(HookType.Forgejo, "https://ci.example/forgejo", HookContentType.Json)) - .map(_ => assertEquals(callCount(backend), 1, "the hook create was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the hook create was repeated")) test("orgs.hooks.edit is retried, because a hook id is a row id the instance never reuses"): val backend = RecordingBackend(flakyThen(200, OrganizationHookApiSuite.HookBody)) @@ -108,13 +108,13 @@ final class OrganizationHookApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.hooks .edit(Org, Hook, EditHook.Empty.activated) - .map(_ => assertEquals(callCount(backend), 2, "the 503 was not retried")) + .map(_ => assertEquals(attemptsOn(backend), 2, "the 503 was not retried")) test("orgs.hooks.delete is retried, on the same argument as the edit"): val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => - api.hooks.delete(Org, Hook).map(_ => assertEquals(callCount(backend), 2, "the 503 was not retried")) + api.hooks.delete(Org, Hook).map(_ => assertEquals(attemptsOn(backend), 2, "the 503 was not retried")) // --- secrets -------------------------------------------------------------- diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApiSuite.scala index de19b8a..fcb22a9 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationLabelApiSuite.scala @@ -102,7 +102,7 @@ final class OrganizationLabelApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.labels.attempt .create(Org, CreateLabel.of(orFail(LabelName.from("bug")), orFail(LabelColor.from("eb6420")))) - .map(_ => assertEquals(callCount(backend), 1, "the label create was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the label create was repeated")) test("orgs.labels.edit is never retried, because a repeat would clobber somebody else's rename"): val backend = RecordingBackend(flakyThen(200, OrganizationLabelApiSuite.LabelBody)) @@ -110,13 +110,13 @@ final class OrganizationLabelApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.labels.attempt .edit(Org, Bug, EditLabel.Empty.archived(true)) - .map(_ => assertEquals(callCount(backend), 1, "the label edit was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the label edit was repeated")) test("orgs.labels.delete is retried, because a label id is a row id the instance never reuses"): val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => - api.labels.delete(Org, Bug).map(_ => assertEquals(callCount(backend), 2, "the 503 was not retried")) + api.labels.delete(Org, Bug).map(_ => assertEquals(attemptsOn(backend), 2, "the 503 was not retried")) // --- payloads and failures ------------------------------------------------ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationStubs.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationStubs.scala index be93699..e0d2313 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationStubs.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationStubs.scala @@ -1,62 +1,24 @@ package com.worxbend.codeberg4s.organizations -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig -import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.paging.PageNumber -import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.users.Username import sttp.client4.Backend -import sttp.client4.testing.BackendStub -import sttp.client4.testing.RecordingBackend -import sttp.client4.testing.ResponseStub -import sttp.model.Header -import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt -/** The stub backend, the pipeline and the assertions the organisation group's suites share. +/** The fixtures the organisation group's suites share, on top of the module-wide [[ClientSuiteHarness]]. * - * [[OrganizationApiSuite]] predates this trait and keeps its own copies; nothing here changes what that suite does. - * Every suite added with the rest of the organisation surface mixes this in instead, so that five suites cannot drift - * into five different ideas of what "the dialled path" means. It is the same arrangement - * [[com.worxbend.codeberg4s.issues.IssueLaneHarness]] makes for the issue group, and it is deliberately shaped the - * same way. - * - * '''Nothing here opens a socket.''' The subject of every suite that uses it is the wiring — which method and URI are - * dialled, which query parameters and which body are sent, what each rail does with a failure — never the network. - * Decoding is asserted in `modules/codec` against the golden captures, so the payloads stubbed here are small, - * hand-written and chosen to exercise one seam each. + * The stub backend, the pipeline and the request assertions live in [[ClientSuiteHarness]], which every API suite in + * this module mixes in. What is left here is only what is specific to the organisation surface: the organisation, the + * team and the account every suite in the group addresses, the `onApi` that builds [[OrganizationApi]], and the error + * bodies they stub. */ -trait OrganizationStubs: +trait OrganizationStubs extends ClientSuiteHarness: self: FunSuite => - /** The execution context every suite's futures run on — munit's own, so a hung assertion fails the test rather than - * the JVM. - */ - given ExecutionContext = munitExecutionContext - - /** The instance every suite pretends to talk to. */ - val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - /** The organisation every suite addresses, matching `golden/organization/org-single.json`. */ val Org: OrgName = orFail(OrgName.from("forgejo")) @@ -66,133 +28,13 @@ trait OrganizationStubs: /** The account every suite names, matching `golden/error/401-user-orgs.json`. */ val Account: Username = orFail(Username.from("earl-warren")) - /** A backend that answers every request with `status` and `body`. */ - def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - /** A backend that answers every request with `status`, `body` and `headers`. */ - def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers a `503` first and then `status` with `body`, for asserting whether a call was repeated. */ - def flakyThen(status: Int, body: String): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic( - ResponseStub.adjust("", StatusCode(503)), - ResponseStub.adjust(body, StatusCode(status)), - ) - - /** The URI the first recorded request dialled, query string and all. */ - def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - /** The query parameters of the first recorded request, in the order they were sent. */ - def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - /** The HTTP method of the first recorded request. */ - def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - /** The body of the first recorded request, as sttp renders it for display. */ - def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - /** What [[bodyOf]] answers for a request that carries no body at all. - * - * sttp renders "no body" as the word `empty` rather than as an empty string, so a suite asserting that a `PUT` or a - * `DELETE` sends nothing compares against this rather than against `""`. Named so that the assertion reads as the - * claim it is making instead of as a sttp implementation detail. - */ - val NoBody: String = "empty" - - /** How many requests reached the backend, which is how a retry is observed. */ - def callCount(backend: RecordingBackend): Int = - backend.allInteractions.size - - /** A pagination window, for the suites that assert on `page` and `limit`. */ - def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - /** Builds the pipeline this group's APIs sit on, hands the caller the API, and releases the timer whatever the - * outcome. - */ + /** Builds [[OrganizationApi]] on a pipeline over `backend`, releasing the timer whatever happens. */ def onApi[A](backend: Backend[Future])(use: OrganizationApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = OrganizationStubs.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) + onPipeline(backend)(pipeline => use(OrganizationApi(pipeline))) - use(OrganizationApi(pipeline)).transform: outcome => - timer.close() - outcome - - /** Asserts that the convenience rail's raised failure and the typed rail's `Left` describe the same thing. - * - * The point of the assertion is that the typed rail is derived from the convenience rail rather than written twice, - * so a divergence between them is a defect no per-method test would catch. - */ - def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - /** The operation id, status and message of an `Api` failure, which is what the two rails must agree on. */ - def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - /** The operation id carried by a materialised failure. */ - def operationOf[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - - /** Unwraps a smart constructor in a fixture, failing the suite rather than the call under test. */ - def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - -/** The retry policy and the error bodies the organisation group's suites share. */ +/** The error bodies the organisation group's suites share. */ object OrganizationStubs: - /** Retries promptly and predictably: the default policy would make every retry test take a quarter of a second. */ - val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) - /** The body Forgejo returns to an anonymous caller on a route that needs a token; `golden/error/401-org-teams.json` * and `golden/error/401-token-required.json` are this shape. */ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala index a6aa448..fe6d894 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/OrganizationTeamApiSuite.scala @@ -164,7 +164,7 @@ final class OrganizationTeamApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.teamAdmin.attempt .create(Org, CreateTeam.named(Reviewers)) - .map(_ => assertEquals(callCount(backend), 1, "the team create was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the team create was repeated")) test("orgs.teams.edit is never retried, because EditTeamOption's required name makes every edit a rename"): val backend = RecordingBackend(flakyThen(200, OrganizationTeamApiSuite.TeamBody)) @@ -172,13 +172,13 @@ final class OrganizationTeamApiSuite extends FunSuite with OrganizationStubs: onApi(backend): api => api.teamAdmin.attempt .edit(Maintainers, EditTeam.named(Reviewers)) - .map(_ => assertEquals(callCount(backend), 1, "the team edit was repeated")) + .map(_ => assertEquals(attemptsOn(backend), 1, "the team edit was repeated")) test("orgs.teams.delete is retried, because a team id is a row id the instance never reuses"): val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => - api.teamAdmin.delete(Maintainers).map(_ => assertEquals(callCount(backend), 2, "the 503 was not retried")) + api.teamAdmin.delete(Maintainers).map(_ => assertEquals(attemptsOn(backend), 2, "the 503 was not retried")) test("both team-membership writes are retried, because each states an end condition about one named team"): val added = RecordingBackend(flakyThen(204, "")) @@ -188,8 +188,8 @@ final class OrganizationTeamApiSuite extends FunSuite with OrganizationStubs: _ <- onApi(added)(api => api.teamAdmin.addMember(Maintainers, Account)) _ <- onApi(removed)(api => api.teamAdmin.removeMember(Maintainers, Account)) yield - assertEquals(callCount(added), 2, "the membership add was not retried") - assertEquals(callCount(removed), 2, "the membership removal was not retried") + assertEquals(attemptsOn(added), 2, "the membership add was not retried") + assertEquals(attemptsOn(removed), 2, "the membership removal was not retried") test("neither team-repository write is retried, because the subject is a renameable org/repo pair"): val added = RecordingBackend(flakyThen(204, "")) @@ -199,8 +199,8 @@ final class OrganizationTeamApiSuite extends FunSuite with OrganizationStubs: _ <- onApi(added)(api => api.teamAdmin.attempt.addRepository(Maintainers, Org, Repo)) _ <- onApi(removed)(api => api.teamAdmin.attempt.removeRepository(Maintainers, Org, Repo)) yield - assertEquals(callCount(added), 1, "the repository grant was repeated") - assertEquals(callCount(removed), 1, "the repository revocation was repeated") + assertEquals(attemptsOn(added), 1, "the repository grant was repeated") + assertEquals(attemptsOn(removed), 1, "the repository revocation was repeated") // --- failures ------------------------------------------------------------- diff --git a/modules/client/test/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApiSuite.scala index 9b45e9f..573f19e 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionApiSuite.scala @@ -1,23 +1,11 @@ package com.worxbend.codeberg4s.organizations.actions -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec import com.worxbend.codeberg4s.codec.Json -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.JitterSource -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.organizations.OrgName import com.worxbend.codeberg4s.paging.PageNumber -import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.actions.CreateVariable import com.worxbend.codeberg4s.repositories.actions.RegisterRunner import com.worxbend.codeberg4s.repositories.actions.RunnerId @@ -28,9 +16,6 @@ import com.worxbend.codeberg4s.repositories.actions.SecretName import com.worxbend.codeberg4s.repositories.actions.SecretValue import com.worxbend.codeberg4s.repositories.actions.UpdateVariable import com.worxbend.codeberg4s.repositories.actions.VariableName -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.testing.BackendStub @@ -41,9 +26,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[OrganizationActionApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -58,11 +41,7 @@ import scala.concurrent.duration.DurationInt * `RepositoryActionApi` on two `DELETE`s, and a difference that is only written in a Scaladoc is a difference that * drifts — so it is pinned here by counting interactions against a backend that fails once and then succeeds. */ -final class OrganizationActionApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext - - private given JitterSource = JitterSource.Deterministic +final class OrganizationActionApiSuite extends FunSuite with ClientSuiteHarness: private val Org: OrgName = orFail(OrgName.from("forgejo")) @@ -72,18 +51,16 @@ final class OrganizationActionApiSuite extends FunSuite: private val Variable: VariableName = orFail(VariableName.from("ENVIRONMENT")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - - private val Root: String = "https://forge.example/api/v1/orgs/forgejo/actions" + private val Endpoint: String = s"$Root/orgs/forgejo/actions" // --- runners -------------------------------------------------------------- test("orgs.actions.runners.list dials the organisation's runners and always states the visibility it wants"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api.listRunners(Org, RunnerVisibility.OwnedOnly, window(2, 25)).map: _ => - assertEquals(pathOf(backend), s"$Root/runners") + assertEquals(pathOf(backend), s"$Endpoint/runners") assertEquals(queryOf(backend).sorted, List("limit" -> "25", "page" -> "2", "visible" -> "false")) test("orgs.actions.runners.list pages from the Link header and not from how many runners came back"): @@ -91,7 +68,7 @@ final class OrganizationActionApiSuite extends FunSuite: responding(200, OrganizationActionApiSuite.RunnerListBody, OrganizationActionApiSuite.PagedHeaders) ) - onBackend(backend): api => + onApi(backend): api => api.listRunners(Org, RunnerVisibility.AllVisible, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(97)) @@ -101,9 +78,9 @@ final class OrganizationActionApiSuite extends FunSuite: test("the single-runner read dials one runner and decodes its status"): val backend = RecordingBackend(responding(200, OrganizationActionApiSuite.RunnerBody)) - onBackend(backend): api => + onApi(backend): api => api.runner(Org, Runner).map: runner => - assertEquals(pathOf(backend), s"$Root/runners/37") + assertEquals(pathOf(backend), s"$Endpoint/runners/37") assertEquals(runner.status, Some(RunnerStatus.Idle)) test("orgs.actions.runners.register POSTs the option body and is never repeated"): @@ -111,17 +88,17 @@ final class OrganizationActionApiSuite extends FunSuite: cycling(503, "", 201, OrganizationActionApiSuite.RegisteredBody) ) - onBackend(backend): api => + onApi(backend): api => api.attempt.registerRunner(Org, orFail(RegisterRunner.named("build-box-3")).ephemeral).map: outcome => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/runners") + assertEquals(pathOf(backend), s"$Endpoint/runners") assertEquals(Json.parse(bodyOf(backend)).toOption.flatMap(_.field("name")).flatMap(_.strOpt), Some("build-box-3")) assertEquals(Json.parse(bodyOf(backend)).toOption.flatMap(_.field("ephemeral")).flatMap(_.boolOpt), Some(true)) assertEquals(backend.allInteractions.size, 1, "a POST was repeated") assert(outcome.isLeft, "the 503 should have reached the caller") test("orgs.actions.runners.register hands back a token that renders as a mask"): - onBackend(responding(201, OrganizationActionApiSuite.RegisteredBody)): api => + onApi(responding(201, OrganizationActionApiSuite.RegisteredBody)): api => api.registerRunner(Org, orFail(RegisterRunner.named("build-box-3"))).map: registered => assertEquals(registered.token.reveal, "QWERTY123") assertEquals(registered.token.toString, "***") @@ -129,36 +106,36 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.runners.delete is repeated, because a runner id is never reused"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteRunner(Org, Runner).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/runners/37") + assertEquals(pathOf(backend), s"$Endpoint/runners/37") assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") test("orgs.actions.runners.registrationToken dials the deprecated endpoint and masks what it returns"): val backend = RecordingBackend(responding(200, """{"token": "REG-TOKEN"}""")) - onBackend(backend): api => + onApi(backend): api => api.runnerRegistrationToken(Org).map: token => - assertEquals(pathOf(backend), s"$Root/runners/registration-token") + assertEquals(pathOf(backend), s"$Endpoint/runners/registration-token") assertEquals(token.reveal, "REG-TOKEN") assertEquals(token.toString, "***") test("orgs.actions.runners.jobs.search comma-joins the labels into the one parameter Forgejo declares"): val backend = RecordingBackend(responding(200, OrganizationActionApiSuite.JobListBody)) - onBackend(backend): api => + onApi(backend): api => val labels = Vector(orFail(RunnerLabel.from("docker")), orFail(RunnerLabel.from("self-hosted"))) api.searchRunnerJobs(Org, labels).map: jobs => - assertEquals(pathOf(backend), s"$Root/runners/jobs") + assertEquals(pathOf(backend), s"$Endpoint/runners/jobs") assertEquals(queryOf(backend), List("labels" -> "docker,self-hosted")) assertEquals(jobs.size, 1) test("orgs.actions.runners.jobs.search with no labels asks for every job rather than for none"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api.searchRunnerJobs(Org, Vector.empty).map(_ => assertEquals(queryOf(backend), List.empty[(String, String)])) // --- secrets -------------------------------------------------------------- @@ -166,29 +143,29 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.secrets.list pages the organisation's secrets and reports no values"): val backend = RecordingBackend(responding(200, OrganizationActionApiSuite.SecretListBody)) - onBackend(backend): api => + onApi(backend): api => api.listSecrets(Org, window(1, 50)).map: page => - assertEquals(pathOf(backend), s"$Root/secrets") + assertEquals(pathOf(backend), s"$Endpoint/secrets") assertEquals(queryOf(backend).sorted, List("limit" -> "50", "page" -> "1")) assertEquals(page.items.map(_.name.value), Vector("DEPLOY_KEY")) test("orgs.actions.secrets.set PUTs the material under 'data' and is repeated, because it sets a stated value"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => api.setSecret(Org, Secret, orFail(SecretValue.from("s3cr3t"))).map: _ => assertEquals(methodOf(backend), "PUT") - assertEquals(pathOf(backend), s"$Root/secrets/DEPLOY_KEY") + assertEquals(pathOf(backend), s"$Endpoint/secrets/DEPLOY_KEY") assertEquals(Json.parse(bodyOf(backend)).toOption.flatMap(_.field("data")).flatMap(_.strOpt), Some("s3cr3t")) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") test("orgs.actions.secrets.delete is NOT repeated, unlike the repository call of the same name"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt.deleteSecret(Org, Secret).map: outcome => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/secrets/DEPLOY_KEY") + assertEquals(pathOf(backend), s"$Endpoint/secrets/DEPLOY_KEY") assertEquals(backend.allInteractions.size, 1, "a delete addressed by a reusable name was retried") assert(outcome.isLeft, "the 503 should have reached the caller") @@ -197,27 +174,27 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.variables.list pages the organisation's variables, values and all"): val backend = RecordingBackend(responding(200, OrganizationActionApiSuite.VariableListBody)) - onBackend(backend): api => + onApi(backend): api => api.listVariables(Org, window(3, 10)).map: page => - assertEquals(pathOf(backend), s"$Root/variables") + assertEquals(pathOf(backend), s"$Endpoint/variables") assertEquals(queryOf(backend).sorted, List("limit" -> "10", "page" -> "3")) assertEquals(page.items.map(_.value), Vector("staging")) test("the single-variable read dials one variable by name"): val backend = RecordingBackend(responding(200, OrganizationActionApiSuite.VariableBody)) - onBackend(backend): api => + onApi(backend): api => api.variable(Org, Variable).map: variable => - assertEquals(pathOf(backend), s"$Root/variables/ENVIRONMENT") + assertEquals(pathOf(backend), s"$Endpoint/variables/ENVIRONMENT") assertEquals(variable.name.value, "ENVIRONMENT") test("orgs.actions.variables.create POSTs only the value and is never repeated"): val backend = RecordingBackend(cycling(503, "", 201, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt.createVariable(Org, Variable, CreateVariable.of("staging")).map: outcome => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/variables/ENVIRONMENT") + assertEquals(pathOf(backend), s"$Endpoint/variables/ENVIRONMENT") assertEquals(Json.parse(bodyOf(backend)).toOption.map(_.keys.toList), Some(List("value"))) assertEquals(backend.allInteractions.size, 1, "a POST was repeated") assert(outcome.isLeft, "the 503 should have reached the caller") @@ -225,7 +202,7 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.variables.update without a rename is repeated"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => api.updateVariable(Org, Variable, UpdateVariable.of("production")).map: _ => assertEquals(methodOf(backend), "PUT") assertEquals(Json.parse(bodyOf(backend)).toOption.flatMap(_.field("value")).flatMap(_.strOpt), Some("production")) @@ -234,7 +211,7 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.variables.update carrying a rename is not repeated, because the old name stops existing"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => val command = UpdateVariable.of("production").movedTo(orFail(VariableName.from("STAGE"))) api.attempt.updateVariable(Org, Variable, command).map: outcome => @@ -245,7 +222,7 @@ final class OrganizationActionApiSuite extends FunSuite: test("orgs.actions.variables.delete is NOT repeated, for the reason the secret delete is not"): val backend = RecordingBackend(cycling(503, "", 204, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt.deleteVariable(Org, Variable).map: outcome => assertEquals(methodOf(backend), "DELETE") assertEquals(backend.allInteractions.size, 1, "a delete addressed by a reusable name was retried") @@ -254,14 +231,14 @@ final class OrganizationActionApiSuite extends FunSuite: // --- both rails ----------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onBackend(responding(404, OrganizationActionApiSuite.NotFoundBody)): api => + onApi(responding(404, OrganizationActionApiSuite.NotFoundBody)): api => api.listSecrets(Org, window(1, 30)).failed.map: case CodebergException(error) => assertEquals(summary(error), (OrganizationActionApi.ListSecretsOperation, 404, Some("GetOrgSecrets"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onBackend(responding(404, OrganizationActionApiSuite.NotFoundBody)): api => + onApi(responding(404, OrganizationActionApiSuite.NotFoundBody)): api => for raised <- api.listSecrets(Org, window(1, 30)).failed typed <- api.attempt.listSecrets(Org, window(1, 30)) @@ -272,7 +249,7 @@ final class OrganizationActionApiSuite extends FunSuite: fail(s"the rails disagreed: $convenience versus $materialised") test("a 400 on a rejected secret name reaches both rails as the same Api failure"): - onBackend(responding(400, OrganizationActionApiSuite.BadRequestBody)): api => + onApi(responding(400, OrganizationActionApiSuite.BadRequestBody)): api => val value = orFail(SecretValue.from("s3cr3t")) for @@ -286,32 +263,22 @@ final class OrganizationActionApiSuite extends FunSuite: fail(s"the rails disagreed: $convenience versus $materialised") test("a 200 whose variable payload names nothing becomes DecodingFailed, never an exception"): - onBackend(responding(200, """{"data": "staging"}""")): api => + onApi(responding(200, """{"data": "staging"}""")): api => api.attempt.variable(Org, Variable).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.name") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a runner listing is reported at its own index"): - onBackend(responding(200, """[{"id": 37}, {"name": "no id here"}]""")): api => + onApi(responding(200, """[{"id": 37}, {"name": "no id here"}]""")): api => api.attempt.listRunners(Org, RunnerVisibility.AllVisible, window(1, 30)).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].id") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - /** An `Api` failure projected onto the parts that do not depend on wall-clock time, so two calls are comparable. */ - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - // --- fixtures ------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: OrganizationActionApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(OrganizationActionApi(pipeline))) /** A backend that answers the first pair once and the second from then on — how a retry is made observable. */ private def cycling(firstStatus: Int, firstBody: String, thenStatus: Int, thenBody: String): BackendStub[Future] = @@ -320,61 +287,6 @@ final class OrganizationActionApiSuite extends FunSuite: ResponseStub.adjust(thenBody, StatusCode(thenStatus)), ) - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: OrganizationActionApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = - CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = OrganizationActionApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(OrganizationActionApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * * All of them are hand-written from `spec/swagger.v1.json`; no organisation Actions endpoint has a golden capture. @@ -413,12 +325,3 @@ object OrganizationActionApiSuite: private val BadRequestBody: String = """{"message":"secret name is invalid","url":"https://codeberg.org/api/swagger"}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala index 592926e..8c5af71 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestApiSuite.scala @@ -1,29 +1,16 @@ package com.worxbend.codeberg4s.pulls -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.issues.LabelId import com.worxbend.codeberg4s.issues.StateFilter import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.testing.BackendStub @@ -34,9 +21,7 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[PullRequestApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -44,9 +29,7 @@ import scala.concurrent.duration.DurationInt * does with a failure, and what the paging headers are allowed to decide. Decoding itself is asserted against the * golden captures in `modules/codec`, so the payloads here are small hand-written bodies chosen to exercise a seam. */ -final class PullRequestApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class PullRequestApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -60,12 +43,10 @@ final class PullRequestApiSuite extends FunSuite: private val Head: CommitSha = orFail(CommitSha.from("48079baa8d387f3ab770cc144c367409ddc2a879")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - // --- reads ---------------------------------------------------------------- test("a single-pull-request read maps the instance's payload to a domain pull request"): - onStub(responding(200, PullRequestApiSuite.MergedBody)): api => + onApi(responding(200, PullRequestApiSuite.MergedBody)): api => api.get(Handle, Name, Number).map: pull => assertEquals(pull.number.value, 13726L) assertEquals(pull.title, "fix: bad quoting in hook scripts") @@ -75,7 +56,7 @@ final class PullRequestApiSuite extends FunSuite: test("a single-pull-request read targets /repos/{owner}/{repo}/pulls/{index} on the configured instance"): val backend = RecordingBackend(responding(200, PullRequestApiSuite.MergedBody)) - onBackend(backend): api => + onApi(backend): api => api .get(Handle, Name, Number) .map(_ => assertEquals(dialled(backend), "https://forge.example/api/v1/repos/forgejo/forgejo/pulls/13726")) @@ -83,7 +64,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.list sends page and limit together, because limit alone is silently ignored"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .list(Handle, Name, PullRequestQuery.Empty, window(2, 25)) .map: _ => @@ -98,7 +79,7 @@ final class PullRequestApiSuite extends FunSuite: .withLabels(Vector(orFail(LabelId.from(201023L)), orFail(LabelId.from(201030L)))) .withBase(Base) - onBackend(backend): api => + onApi(backend): api => api .list(Handle, Name, query, PageParams.First) .map: _ => @@ -116,7 +97,7 @@ final class PullRequestApiSuite extends FunSuite: ) test("pulls.list ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, PullRequestApiSuite.PullListBody, PullRequestApiSuite.PagedHeaders)): api => + onApi(responding(200, PullRequestApiSuite.PullListBody, PullRequestApiSuite.PagedHeaders)): api => api.list(Handle, Name, PullRequestQuery.Empty, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(167)) @@ -124,7 +105,7 @@ final class PullRequestApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page past the end is an empty page, not a failure — Forgejo answers 200 with []"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.list(Handle, Name, PullRequestQuery.Empty, PageParams.First).map: page => assertEquals(page.items, Vector.empty[PullRequest]) assertEquals(page.isLast, true) @@ -132,7 +113,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.reviews.list targets the pull request's reviews and always reports itself as the last page"): val backend = RecordingBackend(responding(200, PullRequestApiSuite.ReviewListBody, PullRequestApiSuite.TotalOnly)) - onBackend(backend): api => + onApi(backend): api => api.listReviews(Handle, Name, Number, PageParams.First).map: page => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/forgejo/forgejo/pulls/13726/reviews") assertEquals(queryOf(backend), List("page" -> "1", "limit" -> "30")) @@ -143,7 +124,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.commits.list decodes the repository wave's commit model"): val backend = RecordingBackend(responding(200, PullRequestApiSuite.CommitListBody)) - onBackend(backend): api => + onApi(backend): api => api.listCommits(Handle, Name, Number, PageParams.First).map: page => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/forgejo/forgejo/pulls/13726/commits") assertEquals(page.items.map(_.sha.value), Vector(Head.value)) @@ -152,7 +133,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.files.list decodes the changed files and their counts"): val backend = RecordingBackend(responding(200, PullRequestApiSuite.FileListBody)) - onBackend(backend): api => + onApi(backend): api => api.listFiles(Handle, Name, Number, PageParams.First).map: page => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/forgejo/forgejo/pulls/13726/files") assertEquals(page.items.map(_.filename), Vector("modules/git/hook_generate.go")) @@ -164,7 +145,7 @@ final class PullRequestApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, PullRequestApiSuite.MergedBody)) val command = orFail(CreatePullRequest.of("fix the quoting", PullRequestHead.branch(Topic), Base)) - onBackend(backend): api => + onApi(backend): api => api.create(Handle, Name, command).map: pull => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/forgejo/forgejo/pulls") @@ -175,7 +156,7 @@ final class PullRequestApiSuite extends FunSuite: val backend = RecordingBackend(cycling(503, 201, PullRequestApiSuite.MergedBody)) val command = orFail(CreatePullRequest.of("fix the quoting", PullRequestHead.branch(Topic), Base)) - onBackend(backend): api => + onApi(backend): api => api.attempt .create(Handle, Name, command) .map: outcome => @@ -185,7 +166,7 @@ final class PullRequestApiSuite extends FunSuite: test("a read is retried, so the eligibility difference is real and not a comment"): val backend = RecordingBackend(cycling(503, 200, PullRequestApiSuite.MergedBody)) - onBackend(backend): api => + onApi(backend): api => api.get(Handle, Name, Number).map: pull => assertEquals(pull.number.value, 13726L) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -193,7 +174,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.edit PATCHes only what the command sets"): val backend = RecordingBackend(responding(201, PullRequestApiSuite.MergedBody)) - onBackend(backend): api => + onApi(backend): api => api .edit(Handle, Name, Number, EditPullRequest.Empty.close) .map: _ => @@ -204,7 +185,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.edit is never retried either, because a partial update is not idempotent here"): val backend = RecordingBackend(cycling(503, 201, PullRequestApiSuite.MergedBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .edit(Handle, Name, Number, EditPullRequest.Empty.close) .map(_ => assertEquals(backend.allInteractions.size, 1, "the PATCH was retried")) @@ -214,7 +195,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.merge POSTs the merge form to the pull request's merge endpoint"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => + onApi(backend): api => api .merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Squash)) .map: _ => @@ -226,7 +207,7 @@ final class PullRequestApiSuite extends FunSuite: test("pulls.merge is never retried, whatever the failure"): val backend = RecordingBackend(cycling(503, 200, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt .merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Merge)) .map: outcome => @@ -236,57 +217,57 @@ final class PullRequestApiSuite extends FunSuite: test("a merge sends the head guard when the caller asked for one, which is what makes a repeat safe"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => + onApi(backend): api => api .merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Merge).expecting(Head)) .map(_ => assertEquals(bodyOf(backend), s"""{"Do":"merge","head_commit_id":"${Head.value}"}""")) test("a merge ignores the response body, so a 200 decorated with a payload still succeeds"): - onStub(responding(200, """{"unexpected":true}""")): api => + onApi(responding(200, """{"unexpected":true}""")): api => api .merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Merge)) .map(outcome => assertEquals(outcome, ())) test("a 405 refusal reaches both rails identically, carrying the merge operation id"): - onStub(responding(405, PullRequestApiSuite.RefusedBody)): api => + onApi(responding(405, PullRequestApiSuite.RefusedBody)): api => for raised <- api.merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Merge)).failed typed <- api.attempt.merge(Handle, Name, Number, MergePullRequest.using(MergeStyle.Merge)) yield - assertEquals(operation(typed), PullRequestApi.MergeOperation) + assertEquals(operationOf(typed), PullRequestApi.MergeOperation) assertRailsAgree(raised, typed) // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, PullRequestApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestApiSuite.NotFoundBody)): api => api.get(Handle, Name, Number).failed.map: case CodebergException(error) => assertEquals(summary(error), (PullRequestApi.GetOperation, 404, Some("GetPullRequestByIndex"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, PullRequestApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestApiSuite.NotFoundBody)): api => for raised <- api.get(Handle, Name, Number).failed typed <- api.attempt.get(Handle, Name, Number) yield assertRailsAgree(raised, typed) test("both rails agree on a review listing failure as well, so the choice of rail is only a choice of style"): - onStub(responding(404, PullRequestApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestApiSuite.NotFoundBody)): api => for raised <- api.listReviews(Handle, Name, Number, PageParams.First).failed typed <- api.attempt.listReviews(Handle, Name, Number, PageParams.First) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, PullRequestApiSuite.ValidationBody)): api => + onApi(responding(400, PullRequestApiSuite.ValidationBody)): api => api.attempt.list(Handle, Name, PullRequestQuery.Empty, PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"id":1,"title":"t","state":"open"}""")): api => + onApi(responding(200, """{"id":1,"title":"t","state":"open"}""")): api => api.attempt.get(Handle, Name, Number).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.number") case other => fail(s"expected a decoding failure, got $other") @@ -294,37 +275,16 @@ final class PullRequestApiSuite extends FunSuite: test("a bad element of a list body reports its position, all the way through the pipeline"): val body = """[{"id":1,"number":1,"title":"t","state":"open"},{"id":2,"number":2,"title":"t"}]""" - onStub(responding(200, body)): api => + onApi(responding(200, body)): api => api.attempt.list(Handle, Name, PullRequestQuery.Empty, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].state") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(PullRequestApi(pipeline))) /** Fails once, then succeeds — the shape every retry-eligibility test needs. */ private def cycling(first: Int, second: Int, body: String): BackendStub[Future] = @@ -333,65 +293,6 @@ final class PullRequestApiSuite extends FunSuite: ResponseStub.adjust(body, StatusCode(second)), ) - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = PullRequestApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(PullRequestApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object PullRequestApiSuite: @@ -471,12 +372,3 @@ object PullRequestApiSuite: /** The ordinary "no" from a merge: Forgejo answers `405` when the merge is refused rather than failing outright. */ private val RefusedBody: String = """{"message":"Merge","url":"https://codeberg.org/api/swagger","errors":["The pull request has merge conflicts"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala index 1356d4b..3cc009d 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/pulls/PullRequestReviewApiSuite.scala @@ -1,24 +1,11 @@ package com.worxbend.codeberg4s.pulls -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import com.worxbend.codeberg4s.users.Username import sttp.client4.Backend @@ -29,19 +16,18 @@ import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** The review-and-reviewer half of [[PullRequestApi]] over a `BackendStub`: nothing here opens a socket. * - * `PullRequestApiSuite` covers the eight operations this group started with. This one covers the eighteen added after - * them, and the subject is the same: which URI is dialled, which method and body are sent, which failures are retried, - * and that both rails report a failure identically. + * `PullRequestReviewApiSuite` covers the eight operations this group started with. This one covers the eighteen added + * after them, and the subject is the same: which URI is dialled, which method and body are sent, which failures are + * retried, and that both rails report a failure identically. */ -final class PullRequestReviewApiSuite extends FunSuite: +final class PullRequestReviewApiSuite extends FunSuite with ClientSuiteHarness: - private given ExecutionContext = munitExecutionContext + /** The prefix every asserted path starts with: the pull-request surface every path in this suite hangs off. */ + private val Endpoint: String = s"$Root/repos/forgejo/forgejo/pulls" private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -61,32 +47,28 @@ final class PullRequestReviewApiSuite extends FunSuite: private val Reviewer: Username = orFail(Username.from("mfenniak")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - - private val Root: String = "https://forge.example/api/v1/repos/forgejo/forgejo/pulls" - // --- the reads that are not paged ----------------------------------------- test("pulls.pinned.list asks for the repository's pinned shortlist and sends no paging"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.PullListBody)) - onBackend(backend): api => + onApi(backend): api => api.listPinned(Handle, Name).map: pinned => - assertEquals(pathOf(backend), s"$Root/pinned") + assertEquals(pathOf(backend), s"$Endpoint/pinned") assertEquals(queryOf(backend), Nil) assertEquals(pinned.map(_.number.value), Vector(13726L)) test("nothing pinned is an empty vector, not a failure — Forgejo answers 200 with []"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.listPinned(Handle, Name).map(pinned => assertEquals(pinned, Vector.empty[PullRequest])) test("pulls.getByBaseHead puts the two branches in the path, base first"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.MergedBody)) - onBackend(backend): api => + onApi(backend): api => api.getByBaseHead(Handle, Name, Base, PullRequestHead.branch(Topic)).map: pull => assertEquals(methodOf(backend), "GET") - assertEquals(pathOf(backend), s"$Root/forgejo/fix-pep691") + assertEquals(pathOf(backend), s"$Endpoint/forgejo/fix-pep691") assertEquals(pull.number.value, 13726L) // --- the diff and the patch ----------------------------------------------- @@ -94,24 +76,24 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.download puts the format in the path as an extension, not in the query"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.DiffBody)) - onBackend(backend): api => + onApi(backend): api => api.download(Handle, Name, Number, DiffRequest.of(DiffFormat.Diff)).map: text => - assertEquals(pathOf(backend), s"$Root/13726.diff") + assertEquals(pathOf(backend), s"$Endpoint/13726.diff") assertEquals(queryOf(backend), Nil) assertEquals(text, PullRequestReviewApiSuite.DiffBody) test("a patch is a different path segment, and the body is handed back unparsed"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.DiffBody)) - onBackend(backend): api => + onApi(backend): api => api .download(Handle, Name, Number, DiffRequest.of(DiffFormat.Patch).includingBinary) .map: _ => - assertEquals(pathOf(backend), s"$Root/13726.patch") + assertEquals(pathOf(backend), s"$Endpoint/13726.patch") assertEquals(queryOf(backend), List("binary" -> "true")) test("a diff that is not JSON still succeeds, because nothing parses it"): - onStub(responding(200, "-----BEGIN NOT JSON-----")): api => + onApi(responding(200, "-----BEGIN NOT JSON-----")): api => api .download(Handle, Name, Number, DiffRequest.of(DiffFormat.Diff)) .map(text => assertEquals(text, "-----BEGIN NOT JSON-----")) @@ -121,36 +103,36 @@ final class PullRequestReviewApiSuite extends FunSuite: test("a 204 from the merge probe means merged"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.isMerged(Handle, Name, Number).map: merged => - assertEquals(pathOf(backend), s"$Root/13726/merge") + assertEquals(pathOf(backend), s"$Endpoint/13726/merge") assertEquals(methodOf(backend), "GET") assertEquals(merged, true) test("a 404 from the merge probe is an answer, not a failure"): - onStub(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => api.isMerged(Handle, Name, Number).map(merged => assertEquals(merged, false)) test("the typed rail reports the same 404 as Right(false), so the rails do not disagree about it"): - onStub(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => api.attempt.isMerged(Handle, Name, Number).map(outcome => assertEquals(outcome, Right(false))) test("every other status still travels on the error channel, so an unreadable repository is not 'unmerged'"): - onStub(responding(403, PullRequestReviewApiSuite.NotFoundBody)): api => + onApi(responding(403, PullRequestReviewApiSuite.NotFoundBody)): api => for raised <- api.isMerged(Handle, Name, Number).failed typed <- api.attempt.isMerged(Handle, Name, Number) yield - assertEquals(operation(typed), PullRequestApi.MergeStatusOperation) + assertEquals(operationOf(typed), PullRequestApi.MergeStatusOperation) assertRailsAgree(raised, typed) test("pulls.merge.cancel deletes the scheduled merge and may be repeated"): val backend = RecordingBackend(cycling(503, 204, "")) - onBackend(backend): api => + onApi(backend): api => api.cancelScheduledMerge(Handle, Name, Number).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/13726/merge") + assertEquals(pathOf(backend), s"$Endpoint/13726/merge") assertEquals(backend.allInteractions.size, 2, "an idempotent cancel was not retried") // --- updating the branch -------------------------------------------------- @@ -158,17 +140,17 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.update names the style in the query and sends no body"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => + onApi(backend): api => api.updateBranch(Handle, Name, Number, UpdateStyle.Rebase).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/update") + assertEquals(pathOf(backend), s"$Endpoint/13726/update") assertEquals(queryOf(backend), List("style" -> "rebase")) /** A retried rebase would rewrite the branch a second time, off a base that may have moved in between. */ test("pulls.update is never retried, because it rewrites a branch"): val backend = RecordingBackend(cycling(503, 200, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt .updateBranch(Handle, Name, Number, UpdateStyle.Merge) .map: outcome => @@ -180,17 +162,17 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.reviewRequests.create posts the reviewers and decodes the rows it created"): val backend = RecordingBackend(responding(201, PullRequestReviewApiSuite.ReviewListBody)) - onBackend(backend): api => + onApi(backend): api => api.requestReviews(Handle, Name, Number, ReviewRequest.of(Reviewer)).map: reviews => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/requested_reviewers") + assertEquals(pathOf(backend), s"$Endpoint/13726/requested_reviewers") assertEquals(bodyOf(backend), """{"reviewers":["mfenniak"]}""") assertEquals(reviews.map(_.state), Vector(Some(ReviewState.Approved))) test("pulls.reviewRequests.create is never retried, because it creates rows and re-notifies"): val backend = RecordingBackend(cycling(503, 201, PullRequestReviewApiSuite.ReviewListBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .requestReviews(Handle, Name, Number, ReviewRequest.of(Reviewer)) .map: outcome => @@ -200,16 +182,16 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.reviewRequests.delete is a DELETE that carries a JSON body, which this endpoint requires"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.removeReviewRequests(Handle, Name, Number, ReviewRequest.of(Reviewer)).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/13726/requested_reviewers") + assertEquals(pathOf(backend), s"$Endpoint/13726/requested_reviewers") assertEquals(bodyOf(backend), """{"reviewers":["mfenniak"]}""") test("withdrawing a request may be repeated, because the body names exactly who is to be removed"): val backend = RecordingBackend(cycling(503, 204, "")) - onBackend(backend): api => + onApi(backend): api => api .removeReviewRequests(Handle, Name, Number, ReviewRequest.of(Reviewer)) .map(_ => assertEquals(backend.allInteractions.size, 2, "an idempotent withdrawal was not retried")) @@ -224,10 +206,10 @@ final class PullRequestReviewApiSuite extends FunSuite: .against(Head) .commenting(orFail(NewReviewComment.onNewLine("modules/git/hook.go", 42L, "here"))) - onBackend(backend): api => + onApi(backend): api => api.createReview(Handle, Name, Number, command).map: review => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/reviews") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews") assertEquals( bodyOf(backend), s"""{"body":"the quoting is still wrong","event":"REQUEST_CHANGES","commit_id":"${Head.value}",""" + @@ -238,7 +220,7 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.reviews.create is never retried, because a repeat leaves two reviews"): val backend = RecordingBackend(cycling(503, 200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .createReview(Handle, Name, Number, CreateReview.Empty) .map: outcome => @@ -248,26 +230,26 @@ final class PullRequestReviewApiSuite extends FunSuite: test("the single-review read addresses a review by its instance-wide id"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api.getReview(Handle, Name, Number, Reviewed).map: review => - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076") assertEquals(review.state, Some(ReviewState.Approved)) test("pulls.reviews.submit posts the event to the review's own path"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api .submitReview(Handle, Name, Number, Reviewed, SubmitReview.saying(ReviewState.Approved).withBody("ship it")) .map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076") assertEquals(bodyOf(backend), """{"event":"APPROVED","body":"ship it"}""") test("pulls.reviews.submit is never retried, because a pending review is consumed by being submitted"): val backend = RecordingBackend(cycling(503, 200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .submitReview(Handle, Name, Number, Reviewed, SubmitReview.saying(ReviewState.Approved)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the submission was sent twice")) @@ -275,31 +257,31 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.reviews.delete removes the review and may be repeated, because ids are never reused"): val backend = RecordingBackend(cycling(503, 204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteReview(Handle, Name, Number, Reviewed).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076") assertEquals(backend.allInteractions.size, 2, "an idempotent delete was not retried") test("pulls.reviews.dismiss posts to the dismissals sub-resource and may be repeated"): val backend = RecordingBackend(cycling(503, 200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api .dismissReview(Handle, Name, Number, Reviewed, DismissReview.Empty.withMessage("superseded")) .map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/dismissals") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/dismissals") assertEquals(bodyOf(backend), """{"message":"superseded"}""") assertEquals(backend.allInteractions.size, 2, "an idempotent dismissal was not retried") test("pulls.reviews.undismiss posts an empty body, because the id is the whole request"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.ReviewBody)) - onBackend(backend): api => + onApi(backend): api => api.undismissReview(Handle, Name, Number, Reviewed).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/undismissals") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/undismissals") assertEquals(bodyOf(backend), "") // --- review comments ------------------------------------------------------ @@ -307,9 +289,9 @@ final class PullRequestReviewApiSuite extends FunSuite: test("pulls.reviews.comments.list reads the whole set, because the endpoint declares no paging"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.CommentListBody)) - onBackend(backend): api => + onApi(backend): api => api.listReviewComments(Handle, Name, Number, Reviewed).map: comments => - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/comments") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/comments") assertEquals(queryOf(backend), Nil) assertEquals(comments.map(_.id.value), Vector(918273L)) assertEquals(comments.flatMap(_.path), Vector("modules/git/hook_generate.go")) @@ -318,7 +300,7 @@ final class PullRequestReviewApiSuite extends FunSuite: val backend = RecordingBackend(cycling(503, 200, PullRequestReviewApiSuite.CommentBody)) val remark = orFail(NewReviewComment.onNewLine("modules/git/hook_generate.go", 42L, "still wrong")) - onBackend(backend): api => + onApi(backend): api => api.attempt .createReviewComment(Handle, Name, Number, Reviewed, remark) .map: outcome => @@ -329,10 +311,10 @@ final class PullRequestReviewApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.CommentBody)) val remark = orFail(NewReviewComment.onOldLine("modules/git/hook_generate.go", 40L, "was fine")) - onBackend(backend): api => + onApi(backend): api => api.createReviewComment(Handle, Name, Number, Reviewed, remark).map: comment => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/comments") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/comments") assertEquals( bodyOf(backend), """{"body":"was fine","path":"modules/git/hook_generate.go","old_position":40}""", @@ -342,75 +324,57 @@ final class PullRequestReviewApiSuite extends FunSuite: test("the single-comment read keeps the two ids in the order the path declares them"): val backend = RecordingBackend(responding(200, PullRequestReviewApiSuite.CommentBody)) - onBackend(backend): api => + onApi(backend): api => api .getReviewComment(Handle, Name, Number, Reviewed, CommentId) - .map(_ => assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/comments/918273")) + .map(_ => assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/comments/918273")) test("pulls.reviews.comments.delete removes one remark and may be repeated"): val backend = RecordingBackend(cycling(503, 204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteReviewComment(Handle, Name, Number, Reviewed, CommentId).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/13726/reviews/1654076/comments/918273") + assertEquals(pathOf(backend), s"$Endpoint/13726/reviews/1654076/comments/918273") assertEquals(backend.allInteractions.size, 2, "an idempotent delete was not retried") // --- failures ------------------------------------------------------------- test("both rails report a review read's 404 identically, so the choice of rail is only a choice of style"): - onStub(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => + onApi(responding(404, PullRequestReviewApiSuite.NotFoundBody)): api => for raised <- api.getReview(Handle, Name, Number, Reviewed).failed typed <- api.attempt.getReview(Handle, Name, Number, Reviewed) yield - assertEquals(operation(typed), PullRequestApi.GetReviewOperation) + assertEquals(operationOf(typed), PullRequestApi.GetReviewOperation) assertRailsAgree(raised, typed) test("both rails report a remark's 422 identically as well"): val remark = orFail(NewReviewComment.onNewLine("a.go", 1L, "x")) - onStub(responding(422, PullRequestReviewApiSuite.ValidationBody)): api => + onApi(responding(422, PullRequestReviewApiSuite.ValidationBody)): api => for raised <- api.createReviewComment(Handle, Name, Number, Reviewed, remark).failed typed <- api.attempt.createReviewComment(Handle, Name, Number, Reviewed, remark) yield assertRailsAgree(raised, typed) test("a review payload that does not fit the model becomes DecodingFailed, never an escaping exception"): - onStub(responding(200, """{"state":"APPROVED"}""")): api => + onApi(responding(200, """{"state":"APPROVED"}""")): api => api.attempt.getReview(Handle, Name, Number, Reviewed).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a comment listing reports its position, all the way through the pipeline"): - onStub(responding(200, """[{"id":1},{"id":0}]""")): api => + onApi(responding(200, """[{"id":1},{"id":0}]""")): api => api.attempt.listReviewComments(Handle, Name, Number, Reviewed).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].id") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), Nil)) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(PullRequestApi(pipeline))) /** Fails once, then succeeds — the shape every retry-eligibility test needs. */ private def cycling(first: Int, second: Int, body: String): BackendStub[Future] = @@ -419,62 +383,6 @@ final class PullRequestReviewApiSuite extends FunSuite: ResponseStub.adjust(body, StatusCode(second)), ) - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def onStub[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: PullRequestApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = PullRequestReviewApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(PullRequestApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * * The review payloads are reductions of `golden/pull/reviews-list.json`. The '''comment''' payloads are not reductions @@ -531,12 +439,3 @@ object PullRequestReviewApiSuite: private val ValidationBody: String = """{"message":"CreatePullReviewComment","url":"https://codeberg.org/api/swagger",""" + """"errors":["the file is not part of this pull request"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala index eaef324..0ddfed6 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/access/RepositoryAccessApiSuite.scala @@ -1,43 +1,25 @@ package com.worxbend.codeberg4s.repositories.access -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.organizations.TeamPermission import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import com.worxbend.codeberg4s.users.User import com.worxbend.codeberg4s.users.Username import sttp.client4.Backend -import sttp.client4.Response -import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend import sttp.client4.testing.ResponseStub -import sttp.client4.testing.StubBody import sttp.model.Header import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[RepositoryAccessApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -52,9 +34,10 @@ import scala.concurrent.duration.DurationInt * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[RepositoryAccessApi]]. */ -final class RepositoryAccessApiSuite extends FunSuite: +final class RepositoryAccessApiSuite extends FunSuite with ClientSuiteHarness: - private given ExecutionContext = munitExecutionContext + /** The prefix every asserted path starts with: the repository every path in this suite hangs off. */ + private val Endpoint: String = s"$Root/repos/forgejo/forgejo" private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -70,18 +53,14 @@ final class RepositoryAccessApiSuite extends FunSuite: private val Squad: TeamName = orFail(TeamName.from("owners")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - - private val Root: String = "https://forge.example/api/v1/repos/forgejo/forgejo" - // --- branch protections --------------------------------------------------- test("repos.branchProtections.list is a bare array and takes no paging parameters"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.BranchListBody)) - onBackend(backend): api => + onApi(backend): api => api.listBranchProtections(Handle, Name).map: rules => - assertEquals(pathOf(backend), s"$Root/branch_protections") + assertEquals(pathOf(backend), s"$Endpoint/branch_protections") assertEquals(queryOf(backend), Nil) assertEquals(rules.map(_.ruleName), Vector("main")) assertEquals(rules.map(_.requireSignedCommits), Vector(true)) @@ -89,9 +68,9 @@ final class RepositoryAccessApiSuite extends FunSuite: test("a single-rule read addresses the rule by its own name"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.BranchBody)) - onBackend(backend): api => + onApi(backend): api => api.branchProtection(Handle, Name, Rule).map: rule => - assertEquals(pathOf(backend), s"$Root/branch_protections/main") + assertEquals(pathOf(backend), s"$Endpoint/branch_protections/main") assertEquals(rule.ruleName, "main") test("repos.branchProtections.create POSTs the rendered options"): @@ -100,10 +79,10 @@ final class RepositoryAccessApiSuite extends FunSuite: .on(Rule) .withSettings(BranchProtectionSettings.Unchanged.requiringSignedCommits(true).applyingToAdmins(true)) - onBackend(backend): api => + onApi(backend): api => api.createBranchProtection(Handle, Name, command).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/branch_protections") + assertEquals(pathOf(backend), s"$Endpoint/branch_protections") assertEquals(bodyOf(backend), """{"rule_name":"main","require_signed_commits":true,"apply_to_admins":true}""") test("repos.branchProtections.create is never retried, because this library repeats no POST"): @@ -114,7 +93,7 @@ final class RepositoryAccessApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.attempt .createBranchProtection(Handle, Name, CreateBranchProtection.on(Rule)) .map: outcome => @@ -125,16 +104,16 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.BranchBody)) val command = EditBranchProtection.of(BranchProtectionSettings.Unchanged.applyingToAdmins(true)) - onBackend(backend): api => + onApi(backend): api => api.editBranchProtection(Handle, Name, Rule, command).map: _ => assertEquals(methodOf(backend), "PATCH") - assertEquals(pathOf(backend), s"$Root/branch_protections/main") + assertEquals(pathOf(backend), s"$Endpoint/branch_protections/main") assertEquals(bodyOf(backend), """{"apply_to_admins":true}""") test("an edit that states nothing sends an empty object rather than resetting the rule"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.BranchBody)) - onBackend(backend): api => + onApi(backend): api => api .editBranchProtection(Handle, Name, Rule, EditBranchProtection.Nothing) .map(_ => assertEquals(bodyOf(backend), "{}")) @@ -147,7 +126,7 @@ final class RepositoryAccessApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.attempt .editBranchProtection(Handle, Name, Rule, EditBranchProtection.Nothing) .map(_ => assertEquals(backend.allInteractions.size, 1, "the PATCH was retried")) @@ -155,16 +134,16 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.branchProtections.delete is a DELETE that reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteBranchProtection(Handle, Name, Rule).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/branch_protections/main") + assertEquals(pathOf(backend), s"$Endpoint/branch_protections/main") test("deleting a rule by name is not retried, because a name the instance reuses could name a different rule"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteBranchProtection(Handle, Name, Rule) .map: outcome => @@ -176,48 +155,48 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.tagProtections.list is a bare array and takes no paging parameters"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.TagListBody)) - onBackend(backend): api => + onApi(backend): api => api.listTagProtections(Handle, Name).map: rules => - assertEquals(pathOf(backend), s"$Root/tag_protections") + assertEquals(pathOf(backend), s"$Endpoint/tag_protections") assertEquals(queryOf(backend), Nil) assertEquals(rules.map(_.namePattern), Vector("v*")) test("a single tag protection read addresses the rule by id"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.TagBody)) - onBackend(backend): api => + onApi(backend): api => api.tagProtection(Handle, Name, TagRule).map: rule => - assertEquals(pathOf(backend), s"$Root/tag_protections/17") + assertEquals(pathOf(backend), s"$Endpoint/tag_protections/17") assertEquals(rule.id.value, 17L) test("repos.tagProtections.create POSTs all three properties, empty whitelists included"): val backend = RecordingBackend(responding(201, RepositoryAccessApiSuite.TagBody)) val command = CreateTagProtection.matching(orFail(TagNamePattern.from("v*"))) - onBackend(backend): api => + onApi(backend): api => api.createTagProtection(Handle, Name, command).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/tag_protections") + assertEquals(pathOf(backend), s"$Endpoint/tag_protections") assertEquals(bodyOf(backend), """{"name_pattern":"v*","whitelist_usernames":[],"whitelist_teams":[]}""") test("repos.tagProtections.edit PATCHes only what the caller stated"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.TagBody)) val command = EditTagProtection.Nothing.exemptingTeams(Vector("release")) - onBackend(backend): api => + onApi(backend): api => api.editTagProtection(Handle, Name, TagRule, command).map: _ => assertEquals(methodOf(backend), "PATCH") - assertEquals(pathOf(backend), s"$Root/tag_protections/17") + assertEquals(pathOf(backend), s"$Endpoint/tag_protections/17") assertEquals(bodyOf(backend), """{"whitelist_teams":["release"]}""") test("deleting a tag protection by id is retried, because the instance never reuses that number"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.deleteTagProtection(Handle, Name, TagRule).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/tag_protections/17") + assertEquals(pathOf(backend), s"$Endpoint/tag_protections/17") assertEquals(backend.allInteractions.size, 2, "a delete by id was not retried") // --- collaborators -------------------------------------------------------- @@ -225,14 +204,14 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.collaborators.list pages the repository's collaborators"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.UserListBody)) - onBackend(backend): api => + onApi(backend): api => api.listCollaborators(Handle, Name, window(2, 25)).map: page => - assertEquals(pathOf(backend), s"$Root/collaborators") + assertEquals(pathOf(backend), s"$Endpoint/collaborators") assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "25")) assertEquals(page.items.map(_.login), Vector("alice")) test("the collaborator listing ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, RepositoryAccessApiSuite.UserListBody, RepositoryAccessApiSuite.PagedHeaders)): api => + onApi(responding(200, RepositoryAccessApiSuite.UserListBody, RepositoryAccessApiSuite.PagedHeaders)): api => api.listCollaborators(Handle, Name, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(97)) @@ -240,7 +219,7 @@ final class RepositoryAccessApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page past the end is an empty page, not a failure"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.listCollaborators(Handle, Name, PageParams.First).map: page => assertEquals(page.items, Vector.empty[User]) assertEquals(page.isLast, true) @@ -248,13 +227,13 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.collaborators.check is a GET with no body, and its 204 is the only yes there is"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.checkCollaborator(Handle, Name, Collaborator).map: _ => assertEquals(methodOf(backend), "GET") - assertEquals(pathOf(backend), s"$Root/collaborators/alice") + assertEquals(pathOf(backend), s"$Endpoint/collaborators/alice") test("a 404 from the collaborator check reaches the typed rail as the no it is"): - onStub(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => api.attempt.checkCollaborator(Handle, Name, Collaborator).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 404) case other => fail(s"expected an Api failure, got $other") @@ -262,17 +241,17 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.collaborators.add PUTs the level under the key the spec names"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.addCollaborator(Handle, Name, Collaborator, CollaboratorPermission.Write).map: _ => assertEquals(methodOf(backend), "PUT") - assertEquals(pathOf(backend), s"$Root/collaborators/alice") + assertEquals(pathOf(backend), s"$Endpoint/collaborators/alice") assertEquals(bodyOf(backend), """{"permission":"write"}""") test("adding a collaborator is retried, because it states a level rather than asserting an absence"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api .addCollaborator(Handle, Name, Collaborator, CollaboratorPermission.Admin) .map(_ => assertEquals(backend.allInteractions.size, 2, "the PUT was not retried")) @@ -281,18 +260,18 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.deleteCollaborator(Handle, Name, Collaborator).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/collaborators/alice") + assertEquals(pathOf(backend), s"$Endpoint/collaborators/alice") assertEquals(backend.allInteractions.size, 2, "the revocation was not retried") test("repos.collaborators.permission reads the level both parsed and verbatim"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.PermissionBody)) - onBackend(backend): api => + onApi(backend): api => api.collaboratorAccess(Handle, Name, Collaborator).map: access => - assertEquals(pathOf(backend), s"$Root/collaborators/alice/permission") + assertEquals(pathOf(backend), s"$Endpoint/collaborators/alice/permission") assertEquals(access.permission, Some(TeamPermission.Write)) assertEquals(access.rawPermission, Some("write")) assertEquals(access.roleName, Some("Collaborator")) @@ -304,9 +283,9 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.DeployKeyListBody)) val query = DeployKeyQuery.Empty.forKeyId(91L).withFingerprint("SHA256:abc") - onBackend(backend): api => + onApi(backend): api => api.listDeployKeys(Handle, Name, query, PageParams.First).map: page => - assertEquals(pathOf(backend), s"$Root/keys") + assertEquals(pathOf(backend), s"$Endpoint/keys") assertEquals( queryOf(backend), List("key_id" -> "91", "fingerprint" -> "SHA256:abc", "page" -> "1", "limit" -> "30"), @@ -316,9 +295,9 @@ final class RepositoryAccessApiSuite extends FunSuite: test("a single deploy key read addresses the grant by id, not by the key it wraps"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.DeployKeyBody)) - onBackend(backend): api => + onApi(backend): api => api.deployKey(Handle, Name, Key).map: deployKey => - assertEquals(pathOf(backend), s"$Root/keys/4") + assertEquals(pathOf(backend), s"$Endpoint/keys/4") assertEquals(deployKey.id.value, 4L) assertEquals(deployKey.keyId, Some(91L)) @@ -326,17 +305,17 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, RepositoryAccessApiSuite.DeployKeyBody)) val command = orFail(CreateDeployKey.of("ci runner", "ssh-ed25519 AAAA deploy@ci")).readOnly - onBackend(backend): api => + onApi(backend): api => api.createDeployKey(Handle, Name, command).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/keys") + assertEquals(pathOf(backend), s"$Endpoint/keys") assertEquals( bodyOf(backend), """{"title":"ci runner","key":"ssh-ed25519 AAAA deploy@ci","read_only":true}""", ) test("a deploy key's public material is not redacted, unlike an Actions secret"): - onStub(responding(200, RepositoryAccessApiSuite.DeployKeyBody)): api => + onApi(responding(200, RepositoryAccessApiSuite.DeployKeyBody)): api => api.deployKey(Handle, Name, Key).map: deployKey => assertEquals(deployKey.key, "ssh-ed25519 AAAAC3Nz deploy@ci") assert(deployKey.toString.contains("ssh-ed25519 AAAAC3Nz"), "the public key was masked, which it need not be") @@ -345,10 +324,10 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.deleteDeployKey(Handle, Name, Key).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/keys/4") + assertEquals(pathOf(backend), s"$Endpoint/keys/4") assertEquals(backend.allInteractions.size, 2, "a delete by id was not retried") // --- teams ---------------------------------------------------------------- @@ -356,35 +335,35 @@ final class RepositoryAccessApiSuite extends FunSuite: test("repos.teams.list is a bare array and takes no paging parameters"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.TeamListBody)) - onBackend(backend): api => + onApi(backend): api => api.listTeams(Handle, Name).map: teams => - assertEquals(pathOf(backend), s"$Root/teams") + assertEquals(pathOf(backend), s"$Endpoint/teams") assertEquals(queryOf(backend), Nil) assertEquals(teams.map(_.name), Vector("owners")) test("repos.teams.check answers with a whole team, unlike its collaborator counterpart"): val backend = RecordingBackend(responding(200, RepositoryAccessApiSuite.TeamBody)) - onBackend(backend): api => + onApi(backend): api => api.checkTeam(Handle, Name, Squad).map: team => - assertEquals(pathOf(backend), s"$Root/teams/owners") + assertEquals(pathOf(backend), s"$Endpoint/teams/owners") assertEquals(team.id.value, 5L) assertEquals(team.permission, Some(TeamPermission.Admin)) test("repos.teams.add is a PUT that carries no body at all"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.addTeam(Handle, Name, Squad).map: _ => assertEquals(methodOf(backend), "PUT") - assertEquals(pathOf(backend), s"$Root/teams/owners") + assertEquals(pathOf(backend), s"$Endpoint/teams/owners") assertEquals(bodyOf(backend), "empty") test("granting a team is not retried, because a team name is not an id the instance never reuses"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.attempt .addTeam(Handle, Name, Squad) .map: outcome => @@ -395,19 +374,19 @@ final class RepositoryAccessApiSuite extends FunSuite: val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteTeam(Handle, Name, Squad) .map: outcome => assert(outcome.isLeft, s"a 503 on a team withdrawal must not be retried into a success, got $outcome") assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/teams/owners") + assertEquals(pathOf(backend), s"$Endpoint/teams/owners") assertEquals(backend.allInteractions.size, 1, "the team withdrawal was retried") // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => api.branchProtection(Handle, Name, Rule).failed.map: case CodebergException(error) => assertEquals( @@ -417,142 +396,60 @@ final class RepositoryAccessApiSuite extends FunSuite: case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryAccessApiSuite.NotFoundBody)): api => for raised <- api.branchProtection(Handle, Name, Rule).failed typed <- api.attempt.branchProtection(Handle, Name, Rule) yield assertRailsAgree(raised, typed) test("both rails agree on a paged listing failure as well, so the choice of rail is only a choice of style"): - onStub(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => for raised <- api.listCollaborators(Handle, Name, PageParams.First).failed typed <- api.attempt.listCollaborators(Handle, Name, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a unit-returning write as well"): - onStub(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => for raised <- api.addCollaborator(Handle, Name, Collaborator, CollaboratorPermission.Read).failed typed <- api.attempt.addCollaborator(Handle, Name, Collaborator, CollaboratorPermission.Read) yield assertRailsAgree(raised, typed) test("a 423 on a protection write is an Api failure like any other status"): - onStub(responding(423, RepositoryAccessApiSuite.ArchivedBody)): api => + onApi(responding(423, RepositoryAccessApiSuite.ArchivedBody)): api => api.attempt.createBranchProtection(Handle, Name, CreateBranchProtection.on(Rule)).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 423) case other => fail(s"expected an Api failure, got $other") test("a 405 on a team endpoint is an Api failure too — it is what a user-owned repository answers"): - onStub(responding(405, RepositoryAccessApiSuite.NotAnOrgBody)): api => + onApi(responding(405, RepositoryAccessApiSuite.NotAnOrgBody)): api => api.attempt.listTeams(Handle, Name).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 405) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"enable_push":true}""")): api => + onApi(responding(200, """{"enable_push":true}""")): api => api.attempt.branchProtection(Handle, Name, Rule).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.rule_name") case other => fail(s"expected a decoding failure, got $other") test("a bad element of a listing reports its position"): - onStub(responding(200, """[{"rule_name":"main"},{"enable_push":true}]""")): api => + onApi(responding(200, """[{"rule_name":"main"},{"enable_push":true}]""")): api => api.attempt.listBranchProtections(Handle, Name).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].rule_name") case other => fail(s"expected a decoding failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAccessApiSuite.ForbiddenBody)): api => api.attempt.deleteDeployKey(Handle, Name, Key).map: outcome => - assertEquals(operation(outcome), RepositoryAccessApi.DeleteDeployKeyOperation) - - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") + assertEquals(operationOf(outcome), RepositoryAccessApi.DeleteDeployKeyOperation) // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers `first` once and `rest` from then on — how a retry is made observable. */ - private def cycling(first: Response[StubBody], rest: Response[StubBody]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic(first, rest) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: RepositoryAccessApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: RepositoryAccessApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = RepositoryAccessApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(RepositoryAccessApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: RepositoryAccessApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(RepositoryAccessApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * @@ -609,12 +506,3 @@ object RepositoryAccessApiSuite: private val NotAnOrgBody: String = """{"message":"ListTeams","errors":["repository is not owned by an organization"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala index 270acf1..5182c49 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala @@ -1,41 +1,24 @@ package com.worxbend.codeberg4s.repositories.actions -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.HttpMethod import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend -import sttp.client4.Response -import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend import sttp.client4.testing.ResponseStub -import sttp.client4.testing.StubBody import sttp.model.Header import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[RepositoryActionApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -46,9 +29,12 @@ import scala.concurrent.duration.DurationInt * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[RepositoryActionApi]]. */ -final class RepositoryActionApiSuite extends FunSuite: +final class RepositoryActionApiSuite extends FunSuite with ClientSuiteHarness: - private given ExecutionContext = munitExecutionContext + /** The prefix every asserted path starts with: the actions surface of the repository every path in this suite hangs + * off. + */ + private val Endpoint: String = s"$Root/repos/forgejo/forgejo/actions" private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -68,24 +54,20 @@ final class RepositoryActionApiSuite extends FunSuite: private val Workflow: WorkflowFileName = orFail(WorkflowFileName.from("build.yml")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - - private val Root: String = "https://forge.example/api/v1/repos/forgejo/forgejo/actions" - // --- artifacts ------------------------------------------------------------ test("actions.artifacts.list targets the repository's artifacts and pages them"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listArtifacts(Handle, Name, ArtifactQuery.Empty.named("coverage"), window(2, 25)) .map: _ => - assertEquals(pathOf(backend), s"$Root/artifacts") + assertEquals(pathOf(backend), s"$Endpoint/artifacts") assertEquals(queryOf(backend), List("name" -> "coverage", "page" -> "2", "limit" -> "25")) test("actions.artifacts.list ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, RepositoryActionApiSuite.ArtifactListBody, RepositoryActionApiSuite.PagedHeaders)): api => + onApi(responding(200, RepositoryActionApiSuite.ArtifactListBody, RepositoryActionApiSuite.PagedHeaders)): api => api.listArtifacts(Handle, Name, ArtifactQuery.Empty, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(97)) @@ -93,7 +75,7 @@ final class RepositoryActionApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page past the end is an empty page, not a failure"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.listArtifacts(Handle, Name, ArtifactQuery.Empty, PageParams.First).map: page => assertEquals(page.items, Vector.empty[ActionArtifact]) assertEquals(page.isLast, true) @@ -101,25 +83,25 @@ final class RepositoryActionApiSuite extends FunSuite: test("a single-artifact read addresses the artifact by id"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.ArtifactBody)) - onBackend(backend): api => + onApi(backend): api => api.artifact(Handle, Name, Artifact).map: artifact => - assertEquals(pathOf(backend), s"$Root/artifacts/881") + assertEquals(pathOf(backend), s"$Endpoint/artifacts/881") assertEquals(artifact.id.value, 881L) assertEquals(artifact.name, Some("coverage")) test("actions.artifacts.delete is a DELETE that reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteArtifact(Handle, Name, Artifact).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/artifacts/881") + assertEquals(pathOf(backend), s"$Endpoint/artifacts/881") test("a delete by id is retried, because deleting a named resource twice leaves the same state"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.deleteArtifact(Handle, Name, Artifact).map(_ => assertEquals(backend.allInteractions.size, 2)) // --- runs ----------------------------------------------------------------- @@ -127,9 +109,9 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.runs.list unwraps the workflow_runs envelope, which no other listing here uses"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.RunListBody)) - onBackend(backend): api => + onApi(backend): api => api.listRuns(Handle, Name, ActionRunQuery.Empty, PageParams.First).map: page => - assertEquals(pathOf(backend), s"$Root/runs") + assertEquals(pathOf(backend), s"$Endpoint/runs") assertEquals(page.items.map(_.id.value), Vector(4711L)) assertEquals(page.items.flatMap(_.status), Vector(ActionStatus.Failure)) @@ -137,7 +119,7 @@ final class RepositoryActionApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, """{"workflow_runs":[]}""")) val query = ActionRunQuery.Empty.triggeredBy("push").withStatus(ActionStatus.Failure).onRef("refs/heads/main") - onBackend(backend): api => + onApi(backend): api => api .listRuns(Handle, Name, query, PageParams.First) .map: _ => @@ -153,7 +135,7 @@ final class RepositoryActionApiSuite extends FunSuite: ) test("a bad entry of the run envelope reports its position under workflow_runs"): - onStub(responding(200, """{"workflow_runs":[{"id":1},{"title":"no id"}]}""")): api => + onApi(responding(200, """{"workflow_runs":[{"id":1},{"title":"no id"}]}""")): api => api.attempt.listRuns(Handle, Name, ActionRunQuery.Empty, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.workflow_runs[1].id") @@ -162,24 +144,24 @@ final class RepositoryActionApiSuite extends FunSuite: test("a single-run read addresses the run by id"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.RunBody)) - onBackend(backend): api => + onApi(backend): api => api.run(Handle, Name, Run).map: run => - assertEquals(pathOf(backend), s"$Root/runs/4711") + assertEquals(pathOf(backend), s"$Endpoint/runs/4711") assertEquals(run.id.value, 4711L) test("actions.runs.cancel POSTs to the run's cancel endpoint with no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.cancelRun(Handle, Name, Run).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/runs/4711/cancel") + assertEquals(pathOf(backend), s"$Endpoint/runs/4711/cancel") test("actions.runs.cancel is never retried, because this library repeats no POST"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.attempt .cancelRun(Handle, Name, Run) .map: outcome => @@ -189,17 +171,17 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.runs.artifacts.list scopes the artifact listing to one run"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listRunArtifacts(Handle, Name, Run, ArtifactQuery.Empty, PageParams.First) - .map(_ => assertEquals(pathOf(backend), s"$Root/runs/4711/artifacts")) + .map(_ => assertEquals(pathOf(backend), s"$Endpoint/runs/4711/artifacts")) test("actions.runs.jobs.list is a bare array and takes no paging parameters"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.JobListBody)) - onBackend(backend): api => + onApi(backend): api => api.listRunJobs(Handle, Name, Run).map: jobs => - assertEquals(pathOf(backend), s"$Root/runs/4711/jobs") + assertEquals(pathOf(backend), s"$Endpoint/runs/4711/jobs") assertEquals(queryOf(backend), Nil) assertEquals(jobs.map(_.id.value), Vector(55L)) @@ -208,20 +190,20 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.jobs.logs returns the body verbatim, without parsing it as JSON"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.LogBody)) - onBackend(backend): api => + onApi(backend): api => api.jobLogs(Handle, Name, Job, None).map: log => - assertEquals(pathOf(backend), s"$Root/jobs/55/logs") + assertEquals(pathOf(backend), s"$Endpoint/jobs/55/logs") assertEquals(queryOf(backend), Nil) assertEquals(log, RepositoryActionApiSuite.LogBody) test("a 206 partial log is a success, because every 2xx is"): - onStub(responding(206, "partial")): api => + onApi(responding(206, "partial")): api => api.jobLogs(Handle, Name, Job, None).map(log => assertEquals(log, "partial")) test("a named attempt is sent as a query parameter"): val backend = RecordingBackend(responding(200, "x")) - onBackend(backend): api => + onApi(backend): api => api .jobLogs(Handle, Name, Job, Some(orFail(JobAttempt.from(3L)))) .map(_ => assertEquals(queryOf(backend), List("attempt" -> "3"))) @@ -231,30 +213,30 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.runners.list always states the visibility it wants"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api .listRunners(Handle, Name, RunnerVisibility.AllVisible, PageParams.First) .map: _ => - assertEquals(pathOf(backend), s"$Root/runners") + assertEquals(pathOf(backend), s"$Endpoint/runners") assertEquals(queryOf(backend), List("visible" -> "true", "page" -> "1", "limit" -> "30")) test("a single-runner read addresses the runner by its string id"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.RunnerBody)) - onBackend(backend): api => + onApi(backend): api => api.runner(Handle, Name, Runner).map: runner => - assertEquals(pathOf(backend), s"$Root/runners/37") + assertEquals(pathOf(backend), s"$Endpoint/runners/37") assertEquals(runner.status, Some(RunnerStatus.Idle)) test("actions.runners.register POSTs the rendered options and returns a masked token"): val backend = RecordingBackend(responding(201, RepositoryActionApiSuite.RegisteredBody)) - onBackend(backend): api => + onApi(backend): api => api .registerRunner(Handle, Name, orFail(RegisterRunner.named("build-box-3")).ephemeral) .map: registered => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/runners") + assertEquals(pathOf(backend), s"$Endpoint/runners") assertEquals(bodyOf(backend), """{"name":"build-box-3","ephemeral":true}""") assertEquals(registered.token.reveal, "QWERTY123") assertEquals(registered.token.toString, RunnerRegistrationToken.Redacted) @@ -267,7 +249,7 @@ final class RepositoryActionApiSuite extends FunSuite: ) ) - onBackend(backend): api => + onApi(backend): api => api.attempt .registerRunner(Handle, Name, orFail(RegisterRunner.named("build-box-3"))) .map(_ => assertEquals(backend.allInteractions.size, 1, "the POST was retried")) @@ -275,7 +257,7 @@ final class RepositoryActionApiSuite extends FunSuite: test("a registration response that does not decode reports no part of the credential"): val truncated = RepositoryActionApiSuite.RegisteredBody.dropRight(1) - onBackend(RecordingBackend(responding(201, truncated))): api => + onApi(RecordingBackend(responding(201, truncated))): api => api.attempt.registerRunner(Handle, Name, orFail(RegisterRunner.named("build-box-3"))).map: case Left(error @ CodebergError.DecodingFailed(_, snippet, _, _)) => assertEquals(snippet, ApiPipeline.redactedSnippet(truncated.length)) @@ -286,17 +268,17 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.runners.delete is a DELETE on the runner"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteRunner(Handle, Name, Runner).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/runners/37") + assertEquals(pathOf(backend), s"$Endpoint/runners/37") test("actions.runners.registrationToken reads the token endpoint and masks what it returns"): val backend = RecordingBackend(responding(200, """{"token":"QWERTY123"}""")) - onBackend(backend): api => + onApi(backend): api => api.runnerRegistrationToken(Handle, Name).map: token => - assertEquals(pathOf(backend), s"$Root/runners/registration-token") + assertEquals(pathOf(backend), s"$Endpoint/runners/registration-token") assertEquals(token.reveal, "QWERTY123") assertEquals(s"$token", RunnerRegistrationToken.Redacted) @@ -304,9 +286,9 @@ final class RepositoryActionApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, "[]")) val labels = Vector(orFail(RunnerLabel.from("ubuntu-latest")), orFail(RunnerLabel.from("docker"))) - onBackend(backend): api => + onApi(backend): api => api.searchRunnerJobs(Handle, Name, labels).map: _ => - assertEquals(pathOf(backend), s"$Root/runners/jobs") + assertEquals(pathOf(backend), s"$Endpoint/runners/jobs") assertEquals(queryOf(backend), List("labels" -> "ubuntu-latest,docker")) // --- tasks ---------------------------------------------------------------- @@ -314,11 +296,11 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.tasks.list unwraps the same envelope the run listing uses"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.TaskListBody)) - onBackend(backend): api => + onApi(backend): api => api .listTasks(Handle, Name, ActionTaskQuery.Empty.withStatus(ActionStatus.Success), PageParams.First) .map: page => - assertEquals(pathOf(backend), s"$Root/tasks") + assertEquals(pathOf(backend), s"$Endpoint/tasks") assertEquals(queryOf(backend), List("status" -> "success", "page" -> "1", "limit" -> "30")) assertEquals(page.items.map(_.id.value), Vector(903L)) @@ -327,33 +309,33 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.secrets.list returns names and timestamps, and has nowhere to put a value"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.SecretListBody)) - onBackend(backend): api => + onApi(backend): api => api.listSecrets(Handle, Name, PageParams.First).map: page => - assertEquals(pathOf(backend), s"$Root/secrets") + assertEquals(pathOf(backend), s"$Endpoint/secrets") assertEquals(page.items.map(_.name.value), Vector("DEPLOY_KEY")) test("actions.secrets.set PUTs the material under the key the spec names"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api .setSecret(Handle, Name, Secret, orFail(SecretValue.from("hunter2"))) .map: _ => assertEquals(methodOf(backend), "PUT") - assertEquals(pathOf(backend), s"$Root/secrets/DEPLOY_KEY") + assertEquals(pathOf(backend), s"$Endpoint/secrets/DEPLOY_KEY") assertEquals(bodyOf(backend), """{"data":"hunter2"}""") test("actions.secrets.set is retried, because setting a named secret twice leaves the same state"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(201)))) - onBackend(backend): api => + onApi(backend): api => api .setSecret(Handle, Name, Secret, orFail(SecretValue.from("hunter2"))) .map(_ => assertEquals(backend.allInteractions.size, 2, "the PUT was not retried")) test("a failed secret write never carries the material into the error a caller would log"): - onStub(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => api.attempt .setSecret(Handle, Name, Secret, orFail(SecretValue.from("hunter2"))) .map: @@ -365,45 +347,45 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.secrets.delete addresses the secret by name"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteSecret(Handle, Name, Secret).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/secrets/DEPLOY_KEY") + assertEquals(pathOf(backend), s"$Endpoint/secrets/DEPLOY_KEY") // --- variables ------------------------------------------------------------ test("actions.variables.list returns values, unlike the secret listing"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.VariableListBody)) - onBackend(backend): api => + onApi(backend): api => api.listVariables(Handle, Name, PageParams.First).map: page => - assertEquals(pathOf(backend), s"$Root/variables") + assertEquals(pathOf(backend), s"$Endpoint/variables") assertEquals(page.items.map(_.value), Vector("staging")) test("a single-variable read addresses the variable by name"): val backend = RecordingBackend(responding(200, RepositoryActionApiSuite.VariableBody)) - onBackend(backend): api => + onApi(backend): api => api.variable(Handle, Name, Variable).map: variable => - assertEquals(pathOf(backend), s"$Root/variables/ENVIRONMENT") + assertEquals(pathOf(backend), s"$Endpoint/variables/ENVIRONMENT") assertEquals(variable.value, "staging") test("actions.variables.create POSTs the value under the key the request model names"): val backend = RecordingBackend(responding(201, "")) - onBackend(backend): api => + onApi(backend): api => api .createVariable(Handle, Name, Variable, CreateVariable.of("staging")) .map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/variables/ENVIRONMENT") + assertEquals(pathOf(backend), s"$Endpoint/variables/ENVIRONMENT") assertEquals(bodyOf(backend), """{"value":"staging"}""") test("actions.variables.update PUTs the value, and is retried when it is not a rename"): val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api .updateVariable(Handle, Name, Variable, UpdateVariable.of("production")) .map: _ => @@ -416,7 +398,7 @@ final class RepositoryActionApiSuite extends FunSuite: RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) val command = UpdateVariable.of("production").movedTo(orFail(VariableName.from("STAGE"))) - onBackend(backend): api => + onApi(backend): api => api.attempt .updateVariable(Handle, Name, Variable, command) .map: outcome => @@ -434,29 +416,29 @@ final class RepositoryActionApiSuite extends FunSuite: test("actions.variables.delete addresses the variable by name"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteVariable(Handle, Name, Variable).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/variables/ENVIRONMENT") + assertEquals(pathOf(backend), s"$Endpoint/variables/ENVIRONMENT") // --- workflows ------------------------------------------------------------ test("actions.workflows.dispatch POSTs the ref to the workflow's dispatches endpoint"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api .dispatchWorkflow(Handle, Name, Workflow, orFail(DispatchWorkflow.on("refs/heads/main"))) .map: outcome => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/workflows/build.yml/dispatches") + assertEquals(pathOf(backend), s"$Endpoint/workflows/build.yml/dispatches") assertEquals(bodyOf(backend), """{"ref":"refs/heads/main"}""") assertEquals(outcome, None) test("a dispatch that asked for run info gets a described run back"): val command = orFail(DispatchWorkflow.on("main")).withInput("environment", "staging").returningRunInfo - onStub(responding(201, RepositoryActionApiSuite.DispatchBody)): api => + onApi(responding(201, RepositoryActionApiSuite.DispatchBody)): api => api.dispatchWorkflow(Handle, Name, Workflow, command).map: outcome => assertEquals(outcome.flatMap(_.id).map(_.value), Some(4711L)) assertEquals(outcome.map(_.jobs), Some(Vector("build"))) @@ -465,7 +447,7 @@ final class RepositoryActionApiSuite extends FunSuite: val backend = RecordingBackend(cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust("", StatusCode(204)))) - onBackend(backend): api => + onApi(backend): api => api.attempt .dispatchWorkflow(Handle, Name, Workflow, orFail(DispatchWorkflow.on("main"))) .map(_ => assertEquals(backend.allInteractions.size, 1, "the POST was retried")) @@ -473,137 +455,55 @@ final class RepositoryActionApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, RepositoryActionApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryActionApiSuite.NotFoundBody)): api => api.run(Handle, Name, Run).failed.map: case CodebergException(error) => assertEquals(summary(error), (RepositoryActionApi.GetRunOperation, 404, Some("GetActionRun"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, RepositoryActionApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryActionApiSuite.NotFoundBody)): api => for raised <- api.run(Handle, Name, Run).failed typed <- api.attempt.run(Handle, Name, Run) yield assertRailsAgree(raised, typed) test("both rails agree on a secret listing failure as well, so the choice of rail is only a choice of style"): - onStub(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => for raised <- api.listSecrets(Handle, Name, PageParams.First).failed typed <- api.attempt.listSecrets(Handle, Name, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a unit-returning write as well"): - onStub(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => for raised <- api.deleteVariable(Handle, Name, Variable).failed typed <- api.attempt.deleteVariable(Handle, Name, Variable) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, RepositoryActionApiSuite.ValidationBody)): api => + onApi(responding(400, RepositoryActionApiSuite.ValidationBody)): api => api.attempt.listRuns(Handle, Name, ActionRunQuery.Empty, PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"title":"no id"}""")): api => + onApi(responding(200, """{"title":"no id"}""")): api => api.attempt.run(Handle, Name, Run).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryActionApiSuite.ForbiddenBody)): api => api.attempt.dispatchWorkflow(Handle, Name, Workflow, orFail(DispatchWorkflow.on("main"))).map: outcome => - assertEquals(operation(outcome), RepositoryActionApi.DispatchWorkflowOperation) - - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") + assertEquals(operationOf(outcome), RepositoryActionApi.DispatchWorkflowOperation) // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers `first` once and `rest` from then on — how a retry is made observable. */ - private def cycling(first: Response[StubBody], rest: Response[StubBody]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic(first, rest) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: RepositoryActionApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: RepositoryActionApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = RepositoryActionApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(RepositoryActionApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: RepositoryActionApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(RepositoryActionApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * @@ -661,12 +561,3 @@ object RepositoryActionApiSuite: private val ValidationBody: String = """{"message":"ListActionRuns","url":"https://codeberg.org/api/swagger","errors":["invalid status"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala index 796c56b..d06eb2f 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApiSuite.scala @@ -1,47 +1,28 @@ package com.worxbend.codeberg4s.repositories.admin -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.issues.TrackedTimeQuery import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.BranchName import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath import com.worxbend.codeberg4s.repositories.gitdata.RefName -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import com.worxbend.codeberg4s.users.Username import sttp.client4.Backend -import sttp.client4.NoBody -import sttp.client4.Response -import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend import sttp.client4.testing.ResponseStub -import sttp.client4.testing.StubBody import sttp.model.Header import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt import java.time.LocalDate @@ -54,9 +35,10 @@ import java.time.LocalDate * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[RepositoryAdminApi]]. */ -final class RepositoryAdminApiSuite extends FunSuite: +final class RepositoryAdminApiSuite extends FunSuite with ClientSuiteHarness: - private given ExecutionContext = munitExecutionContext + /** The prefix every asserted path starts with: the repository every path in this suite hangs off. */ + private val Endpoint: String = s"$Root/repos/forgejo/forgejo" private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -70,16 +52,12 @@ final class RepositoryAdminApiSuite extends FunSuite: private val Sha: CommitSha = orFail(CommitSha.from("abcd1234")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - - private val Root: String = "https://forge.example/api/v1/repos/forgejo/forgejo" - // --- the repository itself ------------------------------------------------ test("repos.admin.create posts to the current user's repositories, not to a slug"): val backend = RecordingBackend(responding(201, RepositoryAdminApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.create(CreateRepository.named(Name).initialised).map: repository => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), "https://forge.example/api/v1/user/repos") @@ -89,23 +67,23 @@ final class RepositoryAdminApiSuite extends FunSuite: test("repos.admin.getById addresses the instance-wide id, which survives a rename"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.byId(orFail(RepositoryId.from(12L))).map: _ => assertEquals(pathOf(backend), "https://forge.example/api/v1/repositories/12") test("repos.admin.edit is a PATCH carrying only the fields the command set"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.edit(Handle, Name, EditRepository.Empty.archivedRepository).map: _ => assertEquals(methodOf(backend), "PATCH") - assertEquals(pathOf(backend), Root) + assertEquals(pathOf(backend), Endpoint) assertEquals(bodyOf(backend), """{"archived":true}""") test("an edit is never retried, because a rename would make the retry address something else"): val backend = retryProbe(200, RepositoryAdminApiSuite.RepositoryBody) - onBackend(backend): api => + onApi(backend): api => api.attempt .edit(Handle, Name, EditRepository.Empty.renamedTo(Name)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the PATCH was retried")) @@ -113,15 +91,15 @@ final class RepositoryAdminApiSuite extends FunSuite: test("repos.admin.delete is a DELETE that reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.delete(Handle, Name).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), Root) + assertEquals(pathOf(backend), Endpoint) test("deleting a repository is never retried, because the name is free the instant it succeeds"): val backend = retryProbe(204, "") - onBackend(backend): api => + onApi(backend): api => api.attempt .delete(Handle, Name) .map(_ => assertEquals(backend.allInteractions.size, 1, "the destructive DELETE was retried")) @@ -133,7 +111,7 @@ final class RepositoryAdminApiSuite extends FunSuite: .usingService(MigrationService.GitHub) .authenticatedWith(orFail(RemoteCredential.from("ghp_SECRET"))) - onBackend(backend): api => + onApi(backend): api => api.migrate(command).map: _ => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/migrate") assert(bodyOf(backend).contains(""""auth_token":"ghp_SECRET""""), "the credential must reach the body") @@ -144,7 +122,7 @@ final class RepositoryAdminApiSuite extends FunSuite: .from("https://github.com/a/b.git", Name) .authenticatedWith(orFail(RemoteCredential.from("ghp_SECRET"))) - onBackend(backend): api => + onApi(backend): api => api.attempt.migrate(command).map: outcome => val rendered = outcome.fold(_.describe, _.toString) @@ -154,9 +132,9 @@ final class RepositoryAdminApiSuite extends FunSuite: test("repos.admin.transfer.start posts the new owner to the transfer endpoint"): val backend = RecordingBackend(responding(202, RepositoryAdminApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.transfer(Handle, Name, TransferRepository.to(Handle)).map: _ => - assertEquals(pathOf(backend), s"$Root/transfer") + assertEquals(pathOf(backend), s"$Endpoint/transfer") assertEquals(bodyOf(backend), """{"new_owner":"forgejo"}""") test("accepting and rejecting a transfer are bodiless POSTs to their own sub-paths"): @@ -164,41 +142,41 @@ final class RepositoryAdminApiSuite extends FunSuite: val rejecting = RecordingBackend(responding(200, RepositoryAdminApiSuite.RepositoryBody)) for - _ <- onBackend(accepting)(api => - api.acceptTransfer(Handle, Name).map(_ => assertEquals(pathOf(accepting), s"$Root/transfer/accept")) + _ <- onApi(accepting)(api => + api.acceptTransfer(Handle, Name).map(_ => assertEquals(pathOf(accepting), s"$Endpoint/transfer/accept")) ) - _ <- onBackend(rejecting)(api => - api.rejectTransfer(Handle, Name).map(_ => assertEquals(pathOf(rejecting), s"$Root/transfer/reject")) + _ <- onApi(rejecting)(api => + api.rejectTransfer(Handle, Name).map(_ => assertEquals(pathOf(rejecting), s"$Endpoint/transfer/reject")) ) yield () test("repos.admin.convert is a bodiless POST"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.convert(Handle, Name).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/convert") + assertEquals(pathOf(backend), s"$Endpoint/convert") // --- mirrors -------------------------------------------------------------- test("repos.admin.mirror.sync posts to mirror-sync and ignores the empty 200 body"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => + onApi(backend): api => api.syncMirror(Handle, Name).map: _ => - assertEquals(pathOf(backend), s"$Root/mirror-sync") + assertEquals(pathOf(backend), s"$Endpoint/mirror-sync") test("repos.admin.pushMirrors.list pages the mirrors"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api.pushMirrors(Handle, Name, window(2, 25)).map: _ => - assertEquals(pathOf(backend), s"$Root/push_mirrors") + assertEquals(pathOf(backend), s"$Endpoint/push_mirrors") assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "25")) test("a mirror listing ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, RepositoryAdminApiSuite.PushMirrorListBody, RepositoryAdminApiSuite.PagedHeaders)): api => + onApi(responding(200, RepositoryAdminApiSuite.PushMirrorListBody, RepositoryAdminApiSuite.PagedHeaders)): api => api.pushMirrors(Handle, Name, window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(97)) @@ -206,7 +184,7 @@ final class RepositoryAdminApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a page past the end is an empty page, not a failure"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.pushMirrors(Handle, Name, PageParams.First).map: page => assertEquals(page.items, Vector.empty[PushMirror]) assertEquals(page.isLast, true) @@ -214,30 +192,30 @@ final class RepositoryAdminApiSuite extends FunSuite: test("a single mirror read addresses it by its generated remote name"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.PushMirrorBody)) - onBackend(backend): api => + onApi(backend): api => api.pushMirror(Handle, Name, Mirror).map: mirror => - assertEquals(pathOf(backend), s"$Root/push_mirrors/remote_a1b2c3") + assertEquals(pathOf(backend), s"$Endpoint/push_mirrors/remote_a1b2c3") assertEquals(mirror.remoteName.value, "remote_a1b2c3") test("adding a push mirror posts the remote address and answers the stored mirror"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.PushMirrorBody)) - onBackend(backend): api => + onApi(backend): api => api.addPushMirror(Handle, Name, CreatePushMirror.to("https://example.test/a.git").overSsh).map: _ => - assertEquals(pathOf(backend), s"$Root/push_mirrors") + assertEquals(pathOf(backend), s"$Endpoint/push_mirrors") assert(bodyOf(backend).contains(""""use_ssh":true"""), bodyOf(backend)) test("deleting a push mirror is retried, because the generated remote name is never handed out again"): val backend = retryProbe(204, "") - onBackend(backend): api => + onApi(backend): api => api.deletePushMirror(Handle, Name, Mirror).map(_ => assertEquals(backend.allInteractions.size, 2)) test("repos.admin.pushMirrors.sync posts to the hyphenated sync path"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => - api.syncPushMirrors(Handle, Name).map(_ => assertEquals(pathOf(backend), s"$Root/push_mirrors-sync")) + onApi(backend): api => + api.syncPushMirrors(Handle, Name).map(_ => assertEquals(pathOf(backend), s"$Endpoint/push_mirrors-sync")) // --- fork syncing --------------------------------------------------------- @@ -246,26 +224,26 @@ final class RepositoryAdminApiSuite extends FunSuite: val branched = RecordingBackend(responding(200, RepositoryAdminApiSuite.SyncForkBody)) for - _ <- onBackend(plain)(api => - api.forkSyncInfo(Handle, Name).map(_ => assertEquals(pathOf(plain), s"$Root/sync_fork")) + _ <- onApi(plain)(api => + api.forkSyncInfo(Handle, Name).map(_ => assertEquals(pathOf(plain), s"$Endpoint/sync_fork")) ) - _ <- onBackend(branched)(api => + _ <- onApi(branched)(api => api .branchForkSyncInfo(Handle, Name, Branch) - .map(_ => assertEquals(pathOf(branched), s"$Root/sync_fork/release/v1")) + .map(_ => assertEquals(pathOf(branched), s"$Endpoint/sync_fork/release/v1")) ) yield () test("a slashed branch name reaches the wire as real separators, not as one escaped segment"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.syncForkBranch(Handle, Name, Branch).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/sync_fork/release/v1") + assertEquals(pathOf(backend), s"$Endpoint/sync_fork/release/v1") test("a fork-sync read reports how far behind the fork is"): - onStub(responding(200, RepositoryAdminApiSuite.SyncForkBody)): api => + onApi(responding(200, RepositoryAdminApiSuite.SyncForkBody)): api => api.forkSyncInfo(Handle, Name).map: info => assertEquals(info.commitsBehind, 12L) assertEquals(info.isBehind, true) @@ -275,29 +253,29 @@ final class RepositoryAdminApiSuite extends FunSuite: test("reading a subscription is a GET on the subscription path"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.WatchBody)) - onBackend(backend): api => + onApi(backend): api => api.subscription(Handle, Name).map: status => - assertEquals(pathOf(backend), s"$Root/subscription") + assertEquals(pathOf(backend), s"$Endpoint/subscription") assertEquals(status.isNotifying, true) test("watching is a PUT that sends no body, because Forgejo declares none"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.WatchBody)) - onBackend(backend): api => + onApi(backend): api => api.watch(Handle, Name).map: _ => assertEquals(methodOf(backend), "PUT") - assertEquals(sendsNoBody(backend), true) + assertEquals(bodyOf(backend), NoBody) test("watching is retried, because it sets one named subscription and creates nothing"): val backend = retryProbe(200, RepositoryAdminApiSuite.WatchBody) - onBackend(backend): api => + onApi(backend): api => api.watch(Handle, Name).map(_ => assertEquals(backend.allInteractions.size, 2)) test("unwatching is retried for the same reason, and answers 204"): val backend = retryProbe(204, "") - onBackend(backend): api => + onApi(backend): api => api.unwatch(Handle, Name).map: _ => assertEquals(backend.allInteractions.size, 2) assertEquals(methodOf(backend), "DELETE") @@ -309,13 +287,13 @@ final class RepositoryAdminApiSuite extends FunSuite: val reviewing = RecordingBackend(responding(200, RepositoryAdminApiSuite.UserListBody)) for - _ <- onBackend(assigning): api => + _ <- onApi(assigning): api => api.assignees(Handle, Name).map: people => - assertEquals(pathOf(assigning), s"$Root/assignees") + assertEquals(pathOf(assigning), s"$Endpoint/assignees") assertEquals(queryOf(assigning), Nil) assertEquals(people.map(_.login), Vector("octocat")) - _ <- onBackend(reviewing)(api => - api.reviewers(Handle, Name).map(_ => assertEquals(pathOf(reviewing), s"$Root/reviewers")) + _ <- onApi(reviewing)(api => + api.reviewers(Handle, Name).map(_ => assertEquals(pathOf(reviewing), s"$Endpoint/reviewers")) ) yield () @@ -324,13 +302,15 @@ final class RepositoryAdminApiSuite extends FunSuite: val watching = RecordingBackend(responding(200, RepositoryAdminApiSuite.UserListBody)) for - _ <- onBackend(starring): api => + _ <- onApi(starring): api => api.stargazers(Handle, Name, window(3, 10)).map: _ => - assertEquals(pathOf(starring), s"$Root/stargazers") + assertEquals(pathOf(starring), s"$Endpoint/stargazers") assertEquals(queryOf(starring), List("page" -> "3", "limit" -> "10")) _ <- - onBackend(watching)(api => - api.subscribers(Handle, Name, PageParams.First).map(_ => assertEquals(pathOf(watching), s"$Root/subscribers")) + onApi(watching)(api => + api.subscribers(Handle, Name, PageParams.First).map(_ => + assertEquals(pathOf(watching), s"$Endpoint/subscribers") + ) ) yield () @@ -339,16 +319,16 @@ final class RepositoryAdminApiSuite extends FunSuite: test("creating a branch posts the new name to the branches collection"): val backend = RecordingBackend(responding(201, RepositoryAdminApiSuite.BranchBody)) - onBackend(backend): api => + onApi(backend): api => api.createBranch(Handle, Name, CreateBranch.named(Branch).startingAt("main")).map: created => - assertEquals(pathOf(backend), s"$Root/branches") + assertEquals(pathOf(backend), s"$Endpoint/branches") assertEquals(bodyOf(backend), """{"new_branch_name":"release/v1","old_ref_name":"main"}""") assertEquals(created.name.value, "release/v1") test("deleting a branch is never retried, because a branch name is reused"): val backend = retryProbe(204, "") - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteBranch(Handle, Name, Branch) .map(_ => assertEquals(backend.allInteractions.size, 1, "the branch DELETE was retried")) @@ -356,10 +336,10 @@ final class RepositoryAdminApiSuite extends FunSuite: test("renaming a branch is a PATCH on the branch's own path, answering nothing"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.renameBranch(Handle, Name, Branch, RenameBranch(orFail(BranchName.from("v1")))).map: _ => assertEquals(methodOf(backend), "PATCH") - assertEquals(pathOf(backend), s"$Root/branches/release/v1") + assertEquals(pathOf(backend), s"$Endpoint/branches/release/v1") assertEquals(bodyOf(backend), """{"name":"v1"}""") // --- contents ------------------------------------------------------------- @@ -369,11 +349,11 @@ final class RepositoryAdminApiSuite extends FunSuite: val pinned = RecordingBackend(responding(200, "[]")) for - _ <- onBackend(defaulted): api => + _ <- onApi(defaulted): api => api.contents(Handle, Name, None).map: _ => - assertEquals(pathOf(defaulted), s"$Root/contents") + assertEquals(pathOf(defaulted), s"$Endpoint/contents") assertEquals(queryOf(defaulted), Nil) - _ <- onBackend(pinned)(api => + _ <- onApi(pinned)(api => api .contents(Handle, Name, Some(orFail(RefName.from("main")))) .map(_ => assertEquals(queryOf(pinned), List("ref" -> "main"))) @@ -383,17 +363,17 @@ final class RepositoryAdminApiSuite extends FunSuite: test("creating a file posts base64 to the file's own path"): val backend = RecordingBackend(responding(201, RepositoryAdminApiSuite.FileResponseBody)) - onBackend(backend): api => + onApi(backend): api => api.createFile(Handle, Name, Path, CreateFile.of(FileBytes.ofText("hello"))).map: change => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/contents/docs/README.md") + assertEquals(pathOf(backend), s"$Endpoint/contents/docs/README.md") assert(bodyOf(backend).contains(""""content":"aGVsbG8=""""), bodyOf(backend)) assertEquals(change.commit.map(_.sha.short), Some("aaaaaaa")) test("updating a file is a PUT carrying the sha guard"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.FileResponseBody)) - onBackend(backend): api => + onApi(backend): api => api.updateFile(Handle, Name, Path, UpdateFile.of(FileBytes.ofText("hi"), Sha)).map: _ => assertEquals(methodOf(backend), "PUT") assert(bodyOf(backend).contains(""""sha":"abcd1234""""), bodyOf(backend)) @@ -401,7 +381,7 @@ final class RepositoryAdminApiSuite extends FunSuite: test("an update is never retried, because after a lost success the sha guard can only report a conflict"): val backend = retryProbe(200, RepositoryAdminApiSuite.FileResponseBody) - onBackend(backend): api => + onApi(backend): api => api.attempt .updateFile(Handle, Name, Path, UpdateFile.of(FileBytes.ofText("hi"), Sha)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the guarded PUT was retried")) @@ -409,17 +389,17 @@ final class RepositoryAdminApiSuite extends FunSuite: test("deleting a file is a DELETE that carries a body, which the spec requires"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.FileDeleteBody)) - onBackend(backend): api => + onApi(backend): api => api.deleteFile(Handle, Name, Path, DeleteFile.of(Sha)).map: change => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/contents/docs/README.md") + assertEquals(pathOf(backend), s"$Endpoint/contents/docs/README.md") assert(bodyOf(backend).contains(""""sha":"abcd1234""""), bodyOf(backend)) assertEquals(change.content, None) test("a file delete is never retried, because it creates a commit"): val backend = retryProbe(200, RepositoryAdminApiSuite.FileDeleteBody) - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteFile(Handle, Name, Path, DeleteFile.of(Sha)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the file DELETE was retried")) @@ -430,9 +410,9 @@ final class RepositoryAdminApiSuite extends FunSuite: .of(FileOperation.Create(orFail(ContentPath.from("a.txt")), FileBytes.ofText("a"))) .and(FileOperation.Delete(orFail(ContentPath.from("b.txt")), Sha)) - onBackend(backend): api => + onApi(backend): api => api.changeFiles(Handle, Name, batch).map: changed => - assertEquals(pathOf(backend), s"$Root/contents") + assertEquals(pathOf(backend), s"$Endpoint/contents") assert(bodyOf(backend).contains(""""operation":"create""""), bodyOf(backend)) assertEquals(changed.files.map(_.meta.name), Vector("a.txt")) @@ -441,16 +421,16 @@ final class RepositoryAdminApiSuite extends FunSuite: test("updating an avatar posts base64 in a JSON body, never a multipart part"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.updateAvatar(Handle, Name, orFail(AvatarImage.ofBase64("aGk="))).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/avatar") + assertEquals(pathOf(backend), s"$Endpoint/avatar") assertEquals(bodyOf(backend), """{"image":"aGk="}""") test("deleting an avatar is retried, because it names one repository and creates nothing"): val backend = retryProbe(204, "") - onBackend(backend): api => + onApi(backend): api => api.deleteAvatar(Handle, Name).map(_ => assertEquals(backend.allInteractions.size, 2)) // --- reporting ------------------------------------------------------------ @@ -458,78 +438,78 @@ final class RepositoryAdminApiSuite extends FunSuite: test("the activity feed sends its calendar day before the paging parameters"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api.activityFeed(Handle, Name, Some(LocalDate.of(2026, 8, 1)), window(1, 20)).map: _ => - assertEquals(pathOf(backend), s"$Root/activities/feeds") + assertEquals(pathOf(backend), s"$Endpoint/activities/feeds") assertEquals(queryOf(backend), List("date" -> "2026-08-01", "page" -> "1", "limit" -> "20")) test("the activity feed omits the day when the caller named none"): val backend = RecordingBackend(responding(200, "[]")) - onBackend(backend): api => + onApi(backend): api => api.activityFeed(Handle, Name, None, PageParams.First).map: _ => assertEquals(queryOf(backend).map((key, _) => key), List("page", "limit")) test("the language statistics decode a bare object into a breakdown"): val backend = RecordingBackend(responding(200, """{"Go": 100, "Scala": 20}""")) - onBackend(backend): api => + onApi(backend): api => api.languages(Handle, Name).map: breakdown => - assertEquals(pathOf(backend), s"$Root/languages") + assertEquals(pathOf(backend), s"$Endpoint/languages") assertEquals(breakdown.dominant, Some("Go")) assertEquals(breakdown.total, 120L) test("the pin-allowance read answers both flags"): val backend = RecordingBackend(responding(200, """{"issues": true, "pull_requests": false}""")) - onBackend(backend): api => + onApi(backend): api => api.newPinAllowed(Handle, Name).map: allowed => - assertEquals(pathOf(backend), s"$Root/new_pin_allowed") + assertEquals(pathOf(backend), s"$Endpoint/new_pin_allowed") assertEquals(allowed, IssuePinsAllowed(issues = true, pullRequests = false)) test("the pinned-issue listing is unpaged and sits under the issues path"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.IssueListBody)) - onBackend(backend): api => + onApi(backend): api => api.pinnedIssues(Handle, Name).map: pinned => - assertEquals(pathOf(backend), s"$Root/issues/pinned") + assertEquals(pathOf(backend), s"$Endpoint/issues/pinned") assertEquals(queryOf(backend), Nil) assertEquals(pinned.map(_.number.value), Vector(42L)) test("the signing key is returned verbatim, because it is not JSON"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.ArmoredKey)) - onBackend(backend): api => + onApi(backend): api => api.signingKey(Handle, Name).map: key => - assertEquals(pathOf(backend), s"$Root/signing-key.gpg") + assertEquals(pathOf(backend), s"$Endpoint/signing-key.gpg") assertEquals(key.map(_.armored), Some(RepositoryAdminApiSuite.ArmoredKey)) test("a repository that signs nothing answers an empty body, which is a success and not a failure"): - onStub(responding(200, "")): api => + onApi(responding(200, "")): api => api.signingKey(Handle, Name).map(key => assertEquals(key, None)) test("the tracked-time listing sends the shared filters before the paging parameters"): val backend = RecordingBackend(responding(200, "[]")) val query = TrackedTimeQuery.Empty.forUser("octocat") - onBackend(backend): api => + onApi(backend): api => api.trackedTimes(Handle, Name, query, window(1, 50)).map: _ => - assertEquals(pathOf(backend), s"$Root/times") + assertEquals(pathOf(backend), s"$Endpoint/times") assertEquals(queryOf(backend), List("user" -> "octocat", "page" -> "1", "limit" -> "50")) test("the per-user tracked-time listing is unpaged, as the spec's own response name says"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.TrackedTimeListBody)) - onBackend(backend): api => + onApi(backend): api => api.trackedTimesFor(Handle, Name, orFail(Username.from("octocat"))).map: entries => - assertEquals(pathOf(backend), s"$Root/times/octocat") + assertEquals(pathOf(backend), s"$Endpoint/times/octocat") assertEquals(queryOf(backend), Nil) assertEquals(entries.map(_.id.value), Vector(5L)) test("the topic search is instance-wide and unwraps the topics envelope"): val backend = RecordingBackend(responding(200, RepositoryAdminApiSuite.TopicSearchBody)) - onBackend(backend): api => + onApi(backend): api => api.searchTopics("scala", window(1, 10)).map: page => assertEquals(pathOf(backend), "https://forge.example/api/v1/topics/search") assertEquals(queryOf(backend), List("q" -> "scala", "page" -> "1", "limit" -> "10")) @@ -538,86 +518,61 @@ final class RepositoryAdminApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, RepositoryAdminApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryAdminApiSuite.NotFoundBody)): api => api.byId(orFail(RepositoryId.from(12L))).failed.map: case CodebergException(error) => assertEquals(summary(error), (RepositoryAdminApi.GetByIdOperation, 404, Some("GetRepositoryByID"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, RepositoryAdminApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryAdminApiSuite.NotFoundBody)): api => for raised <- api.byId(orFail(RepositoryId.from(12L))).failed typed <- api.attempt.byId(orFail(RepositoryId.from(12L))) yield assertRailsAgree(raised, typed) test("both rails agree on a paged listing failure as well, so the choice of rail is only a choice of style"): - onStub(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => for raised <- api.pushMirrors(Handle, Name, PageParams.First).failed typed <- api.attempt.pushMirrors(Handle, Name, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a unit-returning write as well"): - onStub(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => for raised <- api.delete(Handle, Name).failed typed <- api.attempt.delete(Handle, Name) yield assertRailsAgree(raised, typed) test("a 423 is an Api failure — it is what every write against an archived repository answers"): - onStub(responding(423, RepositoryAdminApiSuite.ArchivedBody)): api => + onApi(responding(423, RepositoryAdminApiSuite.ArchivedBody)): api => api.attempt.createFile(Handle, Name, Path, CreateFile.of(FileBytes.ofText("x"))).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 423) case other => fail(s"expected an Api failure, got $other") test("a 409 from a guarded update is an Api failure, which is how a concurrent edit surfaces"): - onStub(responding(409, RepositoryAdminApiSuite.ConflictBody)): api => + onApi(responding(409, RepositoryAdminApiSuite.ConflictBody)): api => api.attempt.updateFile(Handle, Name, Path, UpdateFile.of(FileBytes.ofText("x"), Sha)).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 409) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"remote_address":"https://example.test"}""")): api => + onApi(responding(200, """{"remote_address":"https://example.test"}""")): api => api.attempt.pushMirror(Handle, Name, Mirror).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.remote_name") case other => fail(s"expected a decoding failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => + onApi(responding(403, RepositoryAdminApiSuite.ForbiddenBody)): api => api.attempt.deleteAvatar(Handle, Name).map: outcome => - assertEquals(operation(outcome), RepositoryAdminApi.DeleteAvatarOperation) - - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") + assertEquals(operationOf(outcome), RepositoryAdminApi.DeleteAvatarOperation) // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers `first` once and `rest` from then on — how a retry is made observable. */ - private def cycling(first: Response[StubBody], rest: Response[StubBody]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic(first, rest) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: RepositoryAdminApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(RepositoryAdminApi(pipeline))) /** A recording backend that fails once with a retryable status and then succeeds. * @@ -628,87 +583,12 @@ final class RepositoryAdminApiSuite extends FunSuite: cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust(body, StatusCode(status))) ) - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - /** Whether the recorded request carried no body at all — sttp models that as `NoBody`, not as an empty string. */ - private def sendsNoBody(backend: RecordingBackend): Boolean = - backend.allInteractions.headOption match - case Some((request, _)) => - request.body match - case NoBody => true - case _ => false - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: RepositoryAdminApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: RepositoryAdminApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = RepositoryAdminApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(RepositoryAdminApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * * All of them are hand-written from `spec/swagger.v1.json`; no endpoint in this group has a golden capture. */ object RepositoryAdminApiSuite: - /** A retry policy with no real waiting, so a retry assertion does not cost a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 2, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) - private val PagedHeaders: List[Header] = List( Header("x-total-count", "97"), diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala index ab60505..7d25f52 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApiSuite.scala @@ -1,40 +1,22 @@ package com.worxbend.codeberg4s.repositories.gitdata -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.CommitSha import com.worxbend.codeberg4s.repositories.ContentPath -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend -import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend -import sttp.client4.testing.ResponseStub import sttp.model.Header -import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt /** [[RepositoryGitApi]] over a `BackendStub`: nothing in this suite opens a socket. * @@ -46,9 +28,7 @@ import scala.concurrent.duration.DurationInt * reaches the wire as several segments rather than percent-encoded whole, the archive format is glued onto the last of * those segments, and the tree listing spells its page size `per_page`. */ -final class RepositoryGitApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class RepositoryGitApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("worxbend")) @@ -56,8 +36,6 @@ final class RepositoryGitApiSuite extends FunSuite: private val Sha: CommitSha = orFail(CommitSha.from("1111111111111111111111111111111111111111")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - private def ref(value: String): RefName = orFail(RefName.from(value)) private def path(value: String): ContentPath = orFail(ContentPath.from(value)) @@ -67,7 +45,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a blob read addresses the blob by its object id"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.BlobBody)) - onBackend(backend): api => + onApi(backend): api => api.getBlob(Handle, Name, Sha).map: blob => assertEquals(pathOf(backend), s"https://forge.example/api/v1/repos/worxbend/codeberg4s/git/blobs/${Sha.value}") assertEquals(blob.content.flatMap(_.text), Some("hello")) @@ -76,7 +54,7 @@ final class RepositoryGitApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, s"[${RepositoryGitApiSuite.BlobBody}]")) val second = orFail(CommitSha.from("2222222222222222222222222222222222222222")) - onBackend(backend): api => + onApi(backend): api => api.getBlobs(Handle, Name, Vector(Sha, second)).map: blobs => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/worxbend/codeberg4s/git/blobs") assertEquals(queryOf(backend), List("shas" -> s"${Sha.value},${second.value}")) @@ -85,7 +63,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("the tree listing sends per_page, not limit — this route is the odd one out"): val backend = RecordingBackend(responding(200, """{"tree":[]}""")) - onBackend(backend): api => + onApi(backend): api => api .listTree(Handle, Name, Sha, false, window(2, 50)) .map: _ => @@ -95,7 +73,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a recursive tree listing says so, and only when asked"): val backend = RecordingBackend(responding(200, """{"tree":[]}""")) - onBackend(backend): api => + onApi(backend): api => api .listTree(Handle, Name, Sha, true, PageParams.First) .map(_ => assertEquals(queryOf(backend).headOption, Some("recursive" -> "true"))) @@ -103,7 +81,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a tree page ends where rel=next says it ends, and not where truncated or a short page suggest"): val backend = responding(200, RepositoryGitApiSuite.TruncatedTreeBody, RepositoryGitApiSuite.PagedHeaders) - onStub(backend): api => + onApi(backend): api => api.listTree(Handle, Name, Sha, true, window(1, 50)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(4210)) @@ -111,7 +89,7 @@ final class RepositoryGitApiSuite extends FunSuite: assertEquals(page.isLast, false) test("a tree page whose response carries no Link header reports itself as the last one"): - onStub(responding(200, RepositoryGitApiSuite.TruncatedTreeBody)): api => + onApi(responding(200, RepositoryGitApiSuite.TruncatedTreeBody)): api => api.listTree(Handle, Name, Sha, true, PageParams.First).map: page => assertEquals(page.isLast, true) assertEquals(page.nextPage, None) @@ -119,7 +97,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a single-commit read sends only the parts the caller took a position on"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.CommitBody)) - onBackend(backend): api => + onApi(backend): api => api .getCommit(Handle, Name, Sha, CommitInclude.Default.withFiles(false)) .map: commit => @@ -133,13 +111,13 @@ final class RepositoryGitApiSuite extends FunSuite: test("a commit read that asserts nothing sends no query at all"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.CommitBody)) - onBackend(backend): api => + onApi(backend): api => api.getCommit(Handle, Name, Sha, CommitInclude.Default).map(_ => assertEquals(queryOf(backend), Nil)) test("a diff is a path suffix and comes back as text, unparsed"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.DiffBody)) - onBackend(backend): api => + onApi(backend): api => api.getCommitDiff(Handle, Name, Sha, DiffType.Diff).map: diff => assertEquals( pathOf(backend), @@ -150,7 +128,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a patch differs from a diff only in the suffix"): val backend = RecordingBackend(responding(200, "")) - onBackend(backend): api => + onApi(backend): api => api .getCommitDiff(Handle, Name, Sha, DiffType.Patch) .map(body => assertEquals((pathOf(backend).endsWith(".patch"), body), (true, ""))) @@ -160,7 +138,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a note read drops the stat parameter, which this route does not declare"): val backend = RecordingBackend(responding(200, """{"message":"seen"}""")) - onBackend(backend): api => + onApi(backend): api => api .getNote(Handle, Name, Sha, CommitInclude.Minimal) .map: note => @@ -171,15 +149,15 @@ final class RepositoryGitApiSuite extends FunSuite: test("setting a note POSTs the one key the model has"): val backend = RecordingBackend(responding(200, """{"message":"seen"}""")) - onBackend(backend): api => + onApi(backend): api => api.setNote(Handle, Name, Sha, "seen").map: _ => assertEquals(methodOf(backend), "POST") assertEquals(bodyOf(backend), """{"message":"seen"}""") test("setting a note is never retried, POST being a POST whatever its effect"): - val backend = RecordingBackend(afterOneOutage("""{"message":"seen"}""", 200)) + val backend = RecordingBackend(flakyThen(200, """{"message":"seen"}""")) - onBackend(backend): api => + onApi(backend): api => api.attempt .setNote(Handle, Name, Sha, "seen") .map: outcome => @@ -189,19 +167,19 @@ final class RepositoryGitApiSuite extends FunSuite: test("removing a note is a DELETE that reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.removeNote(Handle, Name, Sha).map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), s"https://forge.example/api/v1/repos/worxbend/codeberg4s/git/notes/${Sha.value}") test("a 204 decorated with an unexpected payload still succeeds, because nothing decodes it"): - onStub(responding(204, """{"unexpected":true}""")): api => + onApi(responding(204, """{"unexpected":true}""")): api => api.attempt.removeNote(Handle, Name, Sha).map(outcome => assertEquals(outcome.isRight, true)) test("removing a note is not retried either — a repeat would answer 404 for work that succeeded"): - val backend = RecordingBackend(afterOneOutage("", 204)) + val backend = RecordingBackend(flakyThen(204, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt .removeNote(Handle, Name, Sha) .map: outcome => @@ -213,7 +191,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("the whole-repository ref listing takes no window, because the route declares none"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.RefsBody)) - onBackend(backend): api => + onApi(backend): api => api.listRefs(Handle, Name).map: refs => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/worxbend/codeberg4s/git/refs") assertEquals(queryOf(backend), Nil) @@ -222,7 +200,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a slashed ref reaches the wire as several segments, because encoding it whole is a 404"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.RefsBody)) - onBackend(backend): api => + onApi(backend): api => api .listMatchingRefs(Handle, Name, ref("refs/heads/main")) .map: _ => @@ -234,7 +212,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("an annotated tag is addressed by the id of the tag object"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.AnnotatedTagBody)) - onBackend(backend): api => + onApi(backend): api => api.getAnnotatedTag(Handle, Name, Sha).map: tag => assertEquals(pathOf(backend), s"https://forge.example/api/v1/repos/worxbend/codeberg4s/git/tags/${Sha.value}") assertEquals(tag.name.value, "v1.0") @@ -244,7 +222,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a combined status windows the nested statuses and keeps the envelope"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.CombinedStatusBody)) - onBackend(backend): api => + onApi(backend): api => api .getCombinedStatus(Handle, Name, ref("main"), window(1, 30)) .map: combined => @@ -257,7 +235,7 @@ final class RepositoryGitApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, "[]")) val query = CommitStatusQuery.Empty.sortedBy(CommitStatusSort.Oldest).inState(CommitStatusState.Failure) - onBackend(backend): api => + onApi(backend): api => api .listStatuses(Handle, Name, ref("v1.0"), query, window(1, 30)) .map: _ => @@ -268,7 +246,7 @@ final class RepositoryGitApiSuite extends FunSuite: ) test("a status page past the end is an empty page, not a failure"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api .listStatuses(Handle, Name, ref("main"), CommitStatusQuery.Empty, PageParams.First) .map: page => @@ -278,7 +256,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a commit's pull request is read through the commits path, not the git path"): val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.PullRequestBody)) - onBackend(backend): api => + onApi(backend): api => api.getCommitPullRequest(Handle, Name, Sha).map: pull => assertEquals( pathOf(backend), @@ -290,7 +268,7 @@ final class RepositoryGitApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.CompareBody)) val range = CompareRange.between(ref("v1.0"), ref("renovate/deps")) - onBackend(backend): api => + onApi(backend): api => api.compare(Handle, Name, range).map: comparison => assertEquals( pathOf(backend), @@ -305,7 +283,7 @@ final class RepositoryGitApiSuite extends FunSuite: val backend = RecordingBackend(responding(200, RepositoryGitApiSuite.FileResponseBody)) val command = ApplyDiffPatch.of("--- a").withMessage("apply") - onBackend(backend): api => + onApi(backend): api => api.applyDiffPatch(Handle, Name, command).map: change => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/worxbend/codeberg4s/diffpatch") @@ -313,17 +291,17 @@ final class RepositoryGitApiSuite extends FunSuite: assertEquals(change.commit.map(_.sha.value), Some(Sha.value)) test("applying a patch is never retried, because a repeat leaves a second commit"): - val backend = RecordingBackend(afterOneOutage(RepositoryGitApiSuite.FileResponseBody, 200)) + val backend = RecordingBackend(flakyThen(200, RepositoryGitApiSuite.FileResponseBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .applyDiffPatch(Handle, Name, ApplyDiffPatch.of("--- a")) .map(_ => assertEquals(backend.allInteractions.size, 1, "the POST was retried")) test("a read is retried, so the eligibility difference is real and not a comment"): - val backend = RecordingBackend(afterOneOutage(RepositoryGitApiSuite.BlobBody, 200)) + val backend = RecordingBackend(flakyThen(200, RepositoryGitApiSuite.BlobBody)) - onBackend(backend): api => + onApi(backend): api => api.getBlob(Handle, Name, Sha).map: blob => assertEquals(blob.size, 5L) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -331,7 +309,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("editorconfig sends the path as segments and the ref as a query parameter"): val backend = RecordingBackend(responding(200, """{"indent_style":"space","indent_size":4}""")) - onBackend(backend): api => + onApi(backend): api => api .getEditorConfig(Handle, Name, path("modules/core/Foo.scala"), Some(ref("refs/heads/main"))) .map: definitions => @@ -345,7 +323,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("a raw file read sends no ref when none was given, and returns the body unchanged"): val backend = RecordingBackend(responding(200, "# codeberg4s\n")) - onBackend(backend): api => + onApi(backend): api => api.getRawFile(Handle, Name, path("README.md"), None).map: body => assertEquals(pathOf(backend), "https://forge.example/api/v1/repos/worxbend/codeberg4s/raw/README.md") assertEquals(queryOf(backend), Nil) @@ -354,7 +332,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("the media read differs from the raw read only in the path segment"): val backend = RecordingBackend(responding(200, "pointer")) - onBackend(backend): api => + onApi(backend): api => api .getMediaFile(Handle, Name, path("assets/logo.png"), Some(ref("main"))) .map: _ => @@ -364,7 +342,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("an archive glues the format onto the last segment of the ref"): val backend = RecordingBackend(responding(200, "PK")) - onBackend(backend): api => + onApi(backend): api => api .getArchive(Handle, Name, ref("release/2026"), ArchiveFormat.TarGz) .map: _ => @@ -376,7 +354,7 @@ final class RepositoryGitApiSuite extends FunSuite: test("an unslashed ref archives as one segment, suffix included"): val backend = RecordingBackend(responding(200, "PK")) - onBackend(backend): api => + onApi(backend): api => api .getArchive(Handle, Name, ref("main"), ArchiveFormat.Zip) .map(_ => @@ -386,160 +364,73 @@ final class RepositoryGitApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => api.getBlob(Handle, Name, Sha).failed.map: case CodebergException(error) => assertEquals(summary(error), (RepositoryGitApi.GetBlobOperation, 404, Some("GetBlob"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => for raised <- api.getBlob(Handle, Name, Sha).failed typed <- api.attempt.getBlob(Handle, Name, Sha) yield assertRailsAgree(raised, typed) test("both rails agree on a tree listing failure too, so the choice of rail is only a choice of style"): - onStub(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => for raised <- api.listTree(Handle, Name, Sha, false, PageParams.First).failed typed <- api.attempt.listTree(Handle, Name, Sha, false, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on the archive read as well, which has no decoder to disagree about"): - onStub(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => for raised <- api.getArchive(Handle, Name, ref("main"), ArchiveFormat.Zip).failed typed <- api.attempt.getArchive(Handle, Name, ref("main"), ArchiveFormat.Zip) yield assertRailsAgree(raised, typed) test("both rails agree on a note deletion failure, where the success carries no value at all"): - onStub(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryGitApiSuite.NotFoundBody)): api => for raised <- api.removeNote(Handle, Name, Sha).failed typed <- api.attempt.removeNote(Handle, Name, Sha) yield assertRailsAgree(raised, typed) test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"size":3}""")): api => + onApi(responding(200, """{"size":3}""")): api => api.attempt.getBlob(Handle, Name, Sha).map: case Left(CodebergError.DecodingFailed(_, _, failed, _)) => assertEquals(failed.render, "$.sha") case other => fail(s"expected a decoding failure, got $other") test("a bad entry of a tree page reports its position, all the way through the pipeline"): - onStub(responding(200, """{"tree":[{"path":"a","sha":"cafebabe"},{"path":"b"}]}""")): api => + onApi(responding(200, """{"tree":[{"path":"a","sha":"cafebabe"},{"path":"b"}]}""")): api => api.attempt.listTree(Handle, Name, Sha, false, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, failed, _)) => assertEquals(failed.render, "$.tree[1].sha") case other => fail(s"expected a decoding failure, got $other") test("a 422 on a patch reaches both rails identically, carrying Forgejo's errors array"): - onStub(responding(422, RepositoryGitApiSuite.ValidationBody)): api => + onApi(responding(422, RepositoryGitApiSuite.ValidationBody)): api => for raised <- api.applyDiffPatch(Handle, Name, ApplyDiffPatch.of("--- a")).failed typed <- api.attempt.applyDiffPatch(Handle, Name, ApplyDiffPatch.of("--- a")) yield - assertEquals(details(typed), List("patch does not apply")) + assertEquals(detailsOf(typed), List("patch does not apply")) assertRailsAgree(raised, typed) test("a 423 on an archived repository is an Api failure carrying the operation id"): - onStub(responding(423, RepositoryGitApiSuite.ValidationBody)): api => + onApi(responding(423, RepositoryGitApiSuite.ValidationBody)): api => api.attempt.applyDiffPatch(Handle, Name, ApplyDiffPatch.of("--- a")).map: case Left(CodebergError.Api(ctx, status, _)) => assertEquals((ctx.operation, status), (RepositoryGitApi.ApplyDiffPatchOperation, 423)) case other => fail(s"expected an Api failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def details[A](result: Either[CodebergError, A]): List[String] = - result match - case Left(CodebergError.Api(_, _, body)) => body.errors - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers `503` once and then succeeds, which is what tells a retried call apart from a bare one. */ - private def afterOneOutage(body: String, status: Int): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic( - ResponseStub.adjust("", StatusCode(503)), - ResponseStub.adjust(body, StatusCode(status)), - ) - - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: RepositoryGitApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: RepositoryGitApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = RepositoryGitApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(RepositoryGitApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: RepositoryGitApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(RepositoryGitApi(pipeline))) /** The response bodies this suite stubs, derived from `spec/swagger.v1.json` and kept out of the test bodies so each * test reads as one behaviour. No golden capture exists for any endpoint in this group. @@ -602,12 +493,3 @@ object RepositoryGitApiSuite: private val ValidationBody: String = """{"message":"ApplyDiffPatch","url":"https://codeberg.org/api/swagger","errors":["patch does not apply"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala index 94febd3..75e16ed 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/repositories/publishing/RepositoryPublishingApiSuite.scala @@ -1,41 +1,24 @@ package com.worxbend.codeberg4s.repositories.publishing -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner import com.worxbend.codeberg4s.RepoName -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.paging.PageNumber import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize import com.worxbend.codeberg4s.repositories.ReleaseId import com.worxbend.codeberg4s.repositories.TagName -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort import sttp.client4.Backend import sttp.client4.MultipartBody import sttp.client4.testing.BackendStub import sttp.client4.testing.RecordingBackend import sttp.client4.testing.ResponseStub -import sttp.model.Header import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext import scala.concurrent.Future -import scala.concurrent.duration.DurationInt import java.nio.charset.StandardCharsets @@ -48,9 +31,7 @@ import java.nio.charset.StandardCharsets * The tag used throughout is `v16.0/forgejo`, taken from `golden/repository/tags-list.json`, because a tag name with a * slash in it is the one that breaks a request builder that percent-encodes the whole name into a single segment. */ -final class RepositoryPublishingApiSuite extends FunSuite: - - private given ExecutionContext = munitExecutionContext +final class RepositoryPublishingApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Owner = orFail(Owner.from("forgejo")) @@ -66,8 +47,6 @@ final class RepositoryPublishingApiSuite extends FunSuite: private val Forge: Topic = orFail(Topic.from("forge")) - private val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) - private val Base: String = "https://forge.example/api/v1/repos/forgejo/forgejo" // --- releases ------------------------------------------------------------- @@ -76,7 +55,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.ReleaseBody)) val command = CreateRelease.of(Version).titled("v16.0.2").asPrerelease - onBackend(backend): api => + onApi(backend): api => api.createRelease(Handle, Name, command).map: release => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), s"$Base/releases") @@ -86,7 +65,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.create is never retried, because a repeat can move a tag as well as publish twice"): val backend = RecordingBackend(flaky(201, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .createRelease(Handle, Name, CreateRelease.of(Version)) .map: outcome => @@ -96,7 +75,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.latest reads the endpoint that excludes drafts and prereleases"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api.latestRelease(Handle, Name).map: release => assertEquals(methodOf(backend), "GET") assertEquals(pathOf(backend), s"$Base/releases/latest") @@ -105,7 +84,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("a read is retried, so the eligibility difference is real and not a comment"): val backend = RecordingBackend(flaky(200, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api.latestRelease(Handle, Name).map: release => assertEquals(release.id.value, 11189746L) assertEquals(backend.allInteractions.size, 2, "the 503 was not retried") @@ -113,7 +92,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.getByTag sends a slashed tag as real path segments, not as one encoded segment"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api .releaseByTag(Handle, Name, Slashed) .map(_ => assertEquals(pathOf(backend), s"$Base/releases/tags/v16.0/forgejo")) @@ -121,7 +100,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.edit PATCHes only what the command sets"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api .editRelease(Handle, Name, Id, EditRelease.Empty.draft(false)) .map: _ => @@ -132,7 +111,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.edit is never retried, because a partial update is not idempotent here"): val backend = RecordingBackend(flaky(200, RepositoryPublishingApiSuite.ReleaseBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .editRelease(Handle, Name, Id, EditRelease.Empty.draft(false)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the PATCH was retried")) @@ -140,7 +119,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.delete addresses the release by id and reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteRelease(Handle, Name, Id).map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), s"$Base/releases/11189746") @@ -148,7 +127,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.delete is never retried, so a 503 is reported rather than repeated"): val backend = RecordingBackend(flaky(204, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteRelease(Handle, Name, Id) .map: outcome => @@ -158,13 +137,13 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.deleteByTag addresses the release by its tag, slashes intact"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteReleaseByTag(Handle, Name, Slashed).map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), s"$Base/releases/tags/v16.0/forgejo") test("a 204 with a body Forgejo should not have sent is still a success, because no decoder runs"): - onStub(responding(204, """{"unexpected":true}""")): api => + onApi(responding(204, """{"unexpected":true}""")): api => api.attempt.deleteRelease(Handle, Name, Id).map(outcome => assertEquals(outcome, Right(()))) // --- release assets ------------------------------------------------------- @@ -172,20 +151,20 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.assets.list pages the release's attachments"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.AssetListBody)) - onBackend(backend): api => + onApi(backend): api => api.listAssets(Handle, Name, Id, window(2, 25)).map: page => assertEquals(pathOf(backend), s"$Base/releases/11189746/assets") assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "25")) assertEquals(page.items.map(_.name), Vector("forgejo-16.0.2-linux-amd64")) test("an attachment listing with no Link header reports itself as the last page, whatever the total says"): - onStub(responding(200, RepositoryPublishingApiSuite.AssetListBody)): api => + onApi(responding(200, RepositoryPublishingApiSuite.AssetListBody)): api => api.listAssets(Handle, Name, Id, PageParams.First).map: page => assertEquals(page.isLast, true) assertEquals(page.nextPage, None) test("a bad element of an attachment listing reports its position, all the way through the pipeline"): - onStub(responding(200, """[{"id":1,"name":"a"},{"id":2}]""")): api => + onApi(responding(200, """[{"id":1,"name":"a"},{"id":2}]""")): api => api.attempt.listAssets(Handle, Name, Id, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[1].name") case other => fail(s"expected a decoding failure, got $other") @@ -194,7 +173,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.AssetBody)) val upload = orFail(UploadAsset.of("forgejo-16.0.2-linux-amd64", RepositoryPublishingApiSuite.Bytes)) - onBackend(backend): api => + onApi(backend): api => api.uploadAsset(Handle, Name, Id, upload).map: asset => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), s"$Base/releases/11189746/assets") @@ -205,14 +184,14 @@ final class RepositoryPublishingApiSuite extends FunSuite: val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.AssetBody)) val upload = orFail(UploadAsset.of("out.tar.gz", RepositoryPublishingApiSuite.Bytes)) - onBackend(backend): api => + onApi(backend): api => api.uploadAsset(Handle, Name, Id, upload).map(_ => assertEquals(queryOf(backend), Nil)) test("an upload sends the name query parameter when the stored name differs from the file name"): val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.AssetBody)) val upload = orFail(UploadAsset.of("out.tar.gz", RepositoryPublishingApiSuite.Bytes)) - onBackend(backend): api => + onApi(backend): api => api .uploadAsset(Handle, Name, Id, upload.named("forgejo-16.0.2-linux-amd64")) .map(_ => assertEquals(queryOf(backend), List("name" -> "forgejo-16.0.2-linux-amd64"))) @@ -221,7 +200,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: val backend = RecordingBackend(flaky(201, RepositoryPublishingApiSuite.AssetBody)) val upload = orFail(UploadAsset.of("checksums.txt", RepositoryPublishingApiSuite.Bytes)) - onBackend(backend): api => + onApi(backend): api => api.attempt .uploadAsset(Handle, Name, Id, upload) .map: outcome => @@ -231,7 +210,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.assets.get addresses the attachment under its release"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.AssetBody)) - onBackend(backend): api => + onApi(backend): api => api.getAsset(Handle, Name, Id, Attachment).map: asset => assertEquals(methodOf(backend), "GET") assertEquals(pathOf(backend), s"$Base/releases/11189746/assets/1730449") @@ -240,7 +219,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.assets.edit PATCHes only what the command sets"): val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.AssetBody)) - onBackend(backend): api => + onApi(backend): api => api .editAsset(Handle, Name, Id, Attachment, EditAsset.Empty.renamedTo("checksums.txt")) .map: _ => @@ -251,7 +230,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.releases.assets.delete removes the attachment and reads no body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.deleteAsset(Handle, Name, Id, Attachment).map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), s"$Base/releases/11189746/assets/1730449") @@ -261,7 +240,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.tags.create POSTs the rendered CreateTagOption"): val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.TagBody)) - onBackend(backend): api => + onApi(backend): api => api .createTag(Handle, Name, CreateTag.of(Version).annotated("security patches")) .map: created => @@ -273,7 +252,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.tags.get sends a slashed tag as real path segments"): val backend = RecordingBackend(responding(200, RepositoryPublishingApiSuite.TagBody)) - onBackend(backend): api => + onApi(backend): api => api.getTag(Handle, Name, Slashed).map: found => assertEquals(pathOf(backend), s"$Base/tags/v16.0/forgejo") assertEquals(found.commitSha.value, "5f7e2e5c003c066a865ea483e42809fa87d85eae") @@ -281,7 +260,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.tags.delete is never retried, because CI recreates tag names"): val backend = RecordingBackend(flaky(204, "")) - onBackend(backend): api => + onApi(backend): api => api.attempt .deleteTag(Handle, Name, Slashed) .map: outcome => @@ -294,7 +273,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.topics.replace PUTs the whole set, and an empty set is an empty array"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.replaceTopics(Handle, Name, Vector.empty).map: _ => assertEquals(methodOf(backend), "PUT") assertEquals(pathOf(backend), s"$Base/topics") @@ -304,7 +283,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: val backend = RecordingBackend(responding(204, "")) val topics = Vector("forge", "forgejo").map(name => orFail(Topic.from(name))) - onBackend(backend): api => + onApi(backend): api => api.replaceTopics(Handle, Name, topics).map(_ => assertEquals(bodyOf(backend), """{"topics":["forge","forgejo"]}""") ) @@ -312,14 +291,14 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.topics.replace is retried, because it re-states a value rather than destroying a resource"): val backend = RecordingBackend(flaky(204, "")) - onBackend(backend): api => + onApi(backend): api => api.replaceTopics(Handle, Name, Vector(Forge)).map: _ => assertEquals(backend.allInteractions.size, 2, "the 503 on a topic replacement was not retried") test("repos.topics.add PUTs the topic as a path segment with a deliberately empty body"): val backend = RecordingBackend(responding(204, "")) - onBackend(backend): api => + onApi(backend): api => api.addTopic(Handle, Name, Forge).map: _ => assertEquals(methodOf(backend), "PUT") assertEquals(pathOf(backend), s"$Base/topics/forge") @@ -328,7 +307,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.topics.add is retried, because asserting set membership twice asserts the same thing"): val backend = RecordingBackend(flaky(204, "")) - onBackend(backend): api => + onApi(backend): api => api .addTopic(Handle, Name, Forge) .map(_ => assertEquals(backend.allInteractions.size, 2, "the add was not retried")) @@ -336,7 +315,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.topics.remove DELETEs the topic and is retried, unlike every other delete in this group"): val backend = RecordingBackend(flaky(204, "")) - onBackend(backend): api => + onApi(backend): api => api.removeTopic(Handle, Name, Forge).map: _ => assertEquals(methodOf(backend), "DELETE") assertEquals(pathOf(backend), s"$Base/topics/forge") @@ -347,7 +326,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.forks.create POSTs to the upstream repository's forks"): val backend = RecordingBackend(responding(202, RepositoryPublishingApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.fork(Handle, Name, CreateFork.Empty.into(Handle)).map: repository => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), s"$Base/forks") @@ -355,14 +334,14 @@ final class RepositoryPublishingApiSuite extends FunSuite: assertEquals(repository.slug.value, "forgejo/forgejo") test("a 202 is a success — forking is accepted now and finished later"): - onStub(responding(202, RepositoryPublishingApiSuite.RepositoryBody)): api => + onApi(responding(202, RepositoryPublishingApiSuite.RepositoryBody)): api => api.attempt.fork(Handle, Name, CreateFork.Empty).map(outcome => assert(outcome.isRight, s"got $outcome")) test("repos.generate names the template in the path and the new repository in the body"): val backend = RecordingBackend(responding(201, RepositoryPublishingApiSuite.RepositoryBody)) val command = GenerateRepository.of(Handle, Name).withGitContent - onBackend(backend): api => + onApi(backend): api => api.generate(Handle, Name, command).map: repository => assertEquals(methodOf(backend), "POST") assertEquals(pathOf(backend), s"$Base/generate") @@ -372,7 +351,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("repos.generate is never retried, because a repeat creates a second repository"): val backend = RecordingBackend(flaky(201, RepositoryPublishingApiSuite.RepositoryBody)) - onBackend(backend): api => + onApi(backend): api => api.attempt .generate(Handle, Name, GenerateRepository.of(Handle, Name)) .map(_ => assertEquals(backend.allInteractions.size, 1, "the generate was retried")) @@ -380,30 +359,30 @@ final class RepositoryPublishingApiSuite extends FunSuite: // --- failures ------------------------------------------------------------- test("a 404 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(404, RepositoryPublishingApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryPublishingApiSuite.NotFoundBody)): api => api.getTag(Handle, Name, Version).failed.map: case CodebergException(error) => assertEquals(summary(error), (RepositoryPublishingApi.GetTagOperation, 404, Some("GetTag"))) case other => fail(s"expected a CodebergException, got $other") test("a 404 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(404, RepositoryPublishingApiSuite.NotFoundBody)): api => + onApi(responding(404, RepositoryPublishingApiSuite.NotFoundBody)): api => for raised <- api.getTag(Handle, Name, Version).failed typed <- api.attempt.getTag(Handle, Name, Version) yield assertRailsAgree(raised, typed) test("both rails agree on a 409 from a create, carrying Forgejo's errors array"): - onStub(responding(409, RepositoryPublishingApiSuite.ConflictBody)): api => + onApi(responding(409, RepositoryPublishingApiSuite.ConflictBody)): api => for raised <- api.createTag(Handle, Name, CreateTag.of(Version)).failed typed <- api.attempt.createTag(Handle, Name, CreateTag.of(Version)) yield - assertEquals(details(typed), List("tag already exists")) + assertEquals(detailsOf(typed), List("tag already exists")) assertRailsAgree(raised, typed) test("both rails agree on a unit-returning failure too, so the choice of rail is only a choice of style"): - onStub(responding(422, RepositoryPublishingApiSuite.ValidationBody)): api => + onApi(responding(422, RepositoryPublishingApiSuite.ValidationBody)): api => for raised <- api.addTopic(Handle, Name, Forge).failed typed <- api.attempt.addTopic(Handle, Name, Forge) @@ -412,7 +391,7 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("both rails agree on an upload failure, bytes and all"): val upload = orFail(UploadAsset.of("checksums.txt", RepositoryPublishingApiSuite.Bytes)) - onStub(responding(413, RepositoryPublishingApiSuite.QuotaBody)): api => + onApi(responding(413, RepositoryPublishingApiSuite.QuotaBody)): api => for raised <- api.uploadAsset(Handle, Name, Id, upload).failed typed <- api.attempt.uploadAsset(Handle, Name, Id, upload) @@ -421,48 +400,22 @@ final class RepositoryPublishingApiSuite extends FunSuite: test("a 413 carries the upload operation id, so an alert can name the endpoint"): val upload = orFail(UploadAsset.of("checksums.txt", RepositoryPublishingApiSuite.Bytes)) - onStub(responding(413, RepositoryPublishingApiSuite.QuotaBody)): api => + onApi(responding(413, RepositoryPublishingApiSuite.QuotaBody)): api => api.attempt .uploadAsset(Handle, Name, Id, upload) - .map(outcome => assertEquals(operation(outcome), RepositoryPublishingApi.UploadAssetOperation)) + .map(outcome => assertEquals(operationOf(outcome), RepositoryPublishingApi.UploadAssetOperation)) test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"name":"v1"}""")): api => + onApi(responding(200, """{"name":"v1"}""")): api => api.attempt.getTag(Handle, Name, Version).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") - // --- assertions ----------------------------------------------------------- - - private def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - private def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - private def details[A](result: Either[CodebergError, A]): List[String] = - result match - case Left(CodebergError.Api(_, _, body)) => body.errors - case other => fail(s"expected an Api failure, got $other") - - private def operation[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") - // --- harness -------------------------------------------------------------- - private def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - private def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ + private def onApi[A](backend: Backend[Future])(use: RepositoryPublishingApi => Future[A]): Future[A] = + onPipeline(backend)(pipeline => use(RepositoryPublishingApi(pipeline))) /** A backend that fails once with a retryable status and then succeeds, so a retry is visible as a second interaction * and its absence as a `Left`. @@ -473,35 +426,6 @@ final class RepositoryPublishingApiSuite extends FunSuite: ResponseStub.adjust(body, StatusCode(status)), ) - private def dialled(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.toString - case None => fail("no request reached the backend") - - /** The dialled URI without its query string. Written with `indexOf` rather than a character comparison because - * `.scalafix.conf` bans universal equality outright. - */ - private def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?') - - if query < 0 then uri else uri.take(query) - - private def queryOf(backend: RecordingBackend): List[(String, String)] = - backend.allInteractions.headOption match - case Some((request, _)) => request.uri.params.toSeq.toList - case None => fail("no request reached the backend") - - private def methodOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.method.method - case None => fail("no request reached the backend") - - private def bodyOf(backend: RecordingBackend): String = - backend.allInteractions.headOption match - case Some((request, _)) => request.body.show.stripPrefix("string: ") - case None => fail("no request reached the backend") - /** The multipart parts of the recorded request, as `(field name, file name)`. */ private def partsOf(backend: RecordingBackend): List[(String, Option[String])] = backend.allInteractions.headOption match @@ -511,36 +435,6 @@ final class RepositoryPublishingApiSuite extends FunSuite: case other => fail(s"expected a multipart body, got ${other.show}") case None => fail("no request reached the backend") - private def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - private def onStub[A](backend: Backend[Future])(use: RepositoryPublishingApi => Future[A]): Future[A] = - onBackend(backend)(use) - - /** Builds the pipeline this group's API sits on, and releases the timer whatever the outcome. */ - private def onBackend[A](backend: Backend[Future])(use: RepositoryPublishingApi => Future[A]): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = RepositoryPublishingApiSuite.Prompt) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(RepositoryPublishingApi(pipeline)).transform: outcome => - timer.close() - outcome - - private def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. */ object RepositoryPublishingApiSuite: @@ -579,12 +473,3 @@ object RepositoryPublishingApiSuite: private val QuotaBody: String = """{"message":"quota exceeded","url":"https://codeberg.org/api/swagger","errors":["storage quota exceeded"]}""" - - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - private val Prompt: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/AccountApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/AccountApiSuite.scala index 618ed01..35c4304 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/AccountApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/AccountApiSuite.scala @@ -1,45 +1,15 @@ package com.worxbend.codeberg4s.users.account -import com.worxbend.codeberg4s.BaseUri -import com.worxbend.codeberg4s.CodebergConfig +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError -import com.worxbend.codeberg4s.CodebergException -import com.worxbend.codeberg4s.ValidationError -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.client.FutureExec -import com.worxbend.codeberg4s.client.FutureTimer -import com.worxbend.codeberg4s.codec.ApiErrorBodyCodec -import com.worxbend.codeberg4s.core.ApiPipeline -import com.worxbend.codeberg4s.core.Exec -import com.worxbend.codeberg4s.core.Telemetry -import com.worxbend.codeberg4s.paging.PageNumber -import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.paging.PageSize -import com.worxbend.codeberg4s.retry.Jitter -import com.worxbend.codeberg4s.retry.RetryPolicy -import com.worxbend.codeberg4s.transport.SttpHttpPort - -import sttp.client4.Backend -import sttp.client4.Response -import sttp.client4.testing.BackendStub -import sttp.client4.testing.RecordingBackend -import sttp.client4.testing.ResponseStub -import sttp.client4.testing.StubBody -import sttp.model.Header -import sttp.model.StatusCode import munit.FunSuite -import scala.concurrent.ExecutionContext -import scala.concurrent.Future -import scala.concurrent.duration.DurationInt - -/** The `BackendStub` harness the five account API suites share: nothing in any of them opens a socket. +/** What the five account API suites share on top of the module-wide [[ClientSuiteHarness]]. * - * Five suites over one pipeline would otherwise carry five copies of the same twelve helpers, and a copy that quietly - * forgot to release the timer would leak a thread per suite until someone noticed. Building the pipeline here, once, - * also means every suite asserts against the same configuration — the same base URI, the same prompt retry policy — so - * a difference between two suites is a difference in the endpoint and never in the harness. + * The stub backend, the pipeline and the request assertions live in [[ClientSuiteHarness]]. What is left here is the + * `/user` prefix every path in this group is built on, the decoding-failure accessor only these suites need, and the + * error bodies they stub. * * The subject of every suite is the wiring: which URI is dialled, which query parameters and which body are sent, * which calls may be repeated, and what each rail does with a failure. Decoding itself is asserted in `modules/codec`, @@ -48,104 +18,10 @@ import scala.concurrent.duration.DurationInt * '''No golden fixture backs this group.''' Every payload in these suites was written from `spec/swagger.v1.json`; * every endpoint under `/user` requires a token and the golden harvest was anonymous. */ -abstract class AccountApiSuite extends FunSuite: - - protected given ExecutionContext = munitExecutionContext - - /** The instance every suite dials, and the prefix every asserted path starts with. */ - protected val Instance: BaseUri = orFail(BaseUri.from("https://forge.example/api/v1")) +abstract class AccountApiSuite extends FunSuite with ClientSuiteHarness: /** The `/user` root every path in this group is built on. */ - protected val Root: String = "https://forge.example/api/v1/user" - - /** A backend that answers `status` and `body` to anything. */ - protected def responding(status: Int, body: String): BackendStub[Future] = - responding(status, body, Nil) - - /** A backend that answers `status`, `body` and `headers` to anything. */ - protected def responding(status: Int, body: String, headers: List[Header]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespond(ResponseStub.adjust(body, StatusCode(status), headers)) - - /** A backend that answers `first` once and `rest` from then on — how a retry is made observable. */ - protected def cycling(first: Response[StubBody], rest: Response[StubBody]): BackendStub[Future] = - BackendStub.asynchronousFuture.whenAnyRequest.thenRespondCyclic(first, rest) - - /** A `503` followed by a success, for asserting whether a call is repeated. */ - protected def failingThenSucceeding(status: Int, body: String): BackendStub[Future] = - cycling(ResponseStub.adjust("", StatusCode(503)), ResponseStub.adjust(body, StatusCode(status))) - - /** The dialled URI without its query string, written with `indexOf` because universal equality is banned. */ - protected def pathOf(backend: RecordingBackend): String = - val uri = dialled(backend) - val query = uri.indexOf('?'.toInt) - - if query < 0 then uri else uri.take(query) - - /** The dialled query parameters, in the order they were sent. */ - protected def queryOf(backend: RecordingBackend): List[(String, String)] = - firstRequest(backend).uri.params.toSeq.toList - - /** The dialled HTTP method. */ - protected def methodOf(backend: RecordingBackend): String = - firstRequest(backend).method.method - - /** The request body as sent, with sttp's own rendering prefix stripped. */ - protected def bodyOf(backend: RecordingBackend): String = - firstRequest(backend).body.show.stripPrefix("string: ") - - /** How many requests reached the backend, which is how a retry decision is asserted. */ - protected def attemptsOn(backend: RecordingBackend): Int = - backend.allInteractions.size - - /** A pagination window, built from values that are validated rather than assumed. */ - protected def window(page: Int, size: Int): PageParams = - PageParams(orFail(PageNumber.from(page)), orFail(PageSize.from(size))) - - /** Builds the pipeline this group's API classes sit on, and releases the timer whatever the outcome. - * - * `use` is a context function over [[com.worxbend.codeberg4s.core.Exec]] because every API class in this package - * needs one to construct, and the instance is created here rather than by each suite — that is the whole point of - * sharing the harness. A plain function would leave the `given` out of scope at the one place it is needed. - */ - protected def onPipeline[A](backend: Backend[Future])( - use: Exec[Future] ?=> ApiPipeline[Future] => Future[A] - ): Future[A] = - given Exec[Future] = FutureExec() - - val config = CodebergConfig(Auth.Anonymous).copy(baseUri = Instance, retry = AccountApiSuite.PromptRetry) - val timer = FutureTimer() - - val pipeline = ApiPipeline[Future]( - SttpHttpPort(backend, config), - config, - timer, - Telemetry.noOp[Future], - ApiErrorBodyCodec.parse, - ) - - use(pipeline).transform: outcome => - timer.close() - outcome - - /** Asserts that the convenience rail's raised failure and the typed rail's `Left` describe the same thing. */ - protected def assertRailsAgree[A](raised: Throwable, typed: Either[CodebergError, A]): Unit = - (raised, typed) match - case (CodebergException(convenience), Left(materialised)) => - assertEquals(summary(materialised), summary(convenience)) - case (convenience, materialised) => - fail(s"the rails disagreed: $convenience versus $materialised") - - /** The three things about an API failure a caller can act on: which operation, which status, which message. */ - protected def summary(error: CodebergError): (String, Int, Option[String]) = - error match - case CodebergError.Api(ctx, status, body) => (ctx.operation, status, body.message) - case other => fail(s"expected an Api failure, got ${other.describe}") - - /** The operation id a typed-rail failure carries, which is what an alert would name. */ - protected def operationOf[A](result: Either[CodebergError, A]): String = - result match - case Left(CodebergError.Api(ctx, _, _)) => ctx.operation - case other => fail(s"expected an Api failure, got $other") + protected val Endpoint: String = s"$Root/user" /** The JSON path a decoding failure blames. */ protected def decodingPathOf[A](result: Either[CodebergError, A]): String = @@ -153,30 +29,8 @@ abstract class AccountApiSuite extends FunSuite: case Left(CodebergError.DecodingFailed(_, _, path, _)) => path.render case other => fail(s"expected a decoding failure, got $other") - protected def orFail[A](result: Either[ValidationError, A]): A = - result match - case Right(value) => value - case Left(error) => fail(s"invalid fixture: ${error.field} ${error.message}") - - private def dialled(backend: RecordingBackend): String = - firstRequest(backend).uri.toString - - private def firstRequest(backend: RecordingBackend): sttp.client4.GenericRequest[?, ?] = - backend.allInteractions.headOption match - case Some((request, _)) => request - case None => fail("no request reached the backend") - object AccountApiSuite: - /** Retries promptly and predictably: the default policy would make the retry tests take a quarter of a second. */ - val PromptRetry: RetryPolicy = RetryPolicy( - maxAttempts = 3, - baseDelay = 1.milli, - maxDelay = 5.millis, - jitter = Jitter.None, - respectRetryAfter = false, - ) - /** The `401` body an endpoint under `/user` answers to an anonymous caller, as `golden/error/401-token-required.json` * captured it. */ diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala index eeb9ac2..4c87237 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserAccountApiSuite.scala @@ -39,7 +39,7 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.settings().map: settings => - assertEquals(pathOf(backend), s"$Root/settings") + assertEquals(pathOf(backend), s"$Endpoint/settings") assertEquals(queryOf(backend), Nil) assertEquals(settings.fullName, Some("A Maintainer")) assertEquals(settings.hidesEmail, true) @@ -50,12 +50,12 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.updateSettings(UpdateUserSettings.Empty.hidingActivity).map: settings => assertEquals(methodOf(backend), "PATCH") - assertEquals(pathOf(backend), s"$Root/settings") + assertEquals(pathOf(backend), s"$Endpoint/settings") assertEquals(bodyOf(backend), """{"hide_activity":true}""") assertEquals(settings.hidesActivity, false) test("the settings update is retried, because it names one identity and states what it wants"): - val backend = RecordingBackend(failingThenSucceeding(200, UserAccountApiSuite.SettingsBody)) + val backend = RecordingBackend(flakyThen(200, UserAccountApiSuite.SettingsBody)) onApi(backend): api => api.updateSettings(UpdateUserSettings.Empty.hidingActivity).map(_ => assertEquals(attemptsOn(backend), 2)) @@ -68,11 +68,11 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.updateAvatar(image).map: _ => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/avatar") + assertEquals(pathOf(backend), s"$Endpoint/avatar") assertEquals(bodyOf(backend), s"""{"image":"$blob"}""") test("the avatar update is never retried, because it is a POST"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.attempt.updateAvatar(image).map(_ => assertEquals(attemptsOn(backend), 1, "the POST was retried")) @@ -83,10 +83,10 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.deleteAvatar().map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/avatar") + assertEquals(pathOf(backend), s"$Endpoint/avatar") test("the avatar delete is retried, because it asks for a state rather than for an object"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.deleteAvatar().map(_ => assertEquals(attemptsOn(backend), 2)) @@ -98,7 +98,7 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.emails().map: addresses => - assertEquals(pathOf(backend), s"$Root/emails") + assertEquals(pathOf(backend), s"$Endpoint/emails") assertEquals(queryOf(backend), Nil) assertEquals(addresses.map(_.address.value), Vector("maintainer@example.org")) assertEquals(addresses.map(_.isPrimary), Vector(true)) @@ -109,12 +109,12 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.addEmails(Address, Second).map: addresses => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/emails") + assertEquals(pathOf(backend), s"$Endpoint/emails") assertEquals(bodyOf(backend), """{"emails":["maintainer@example.org","second@example.org"]}""") assertEquals(addresses.length, 1) test("adding an address is never retried, because a repeat turns a success into a 422"): - val backend = RecordingBackend(failingThenSucceeding(201, UserAccountApiSuite.EmailListBody)) + val backend = RecordingBackend(flakyThen(201, UserAccountApiSuite.EmailListBody)) onApi(backend): api => api.attempt.addEmails(Address).map(_ => assertEquals(attemptsOn(backend), 1, "the POST was retried")) @@ -125,11 +125,11 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.deleteEmails(Address).map: _ => assertEquals(methodOf(backend), "DELETE") - assertEquals(pathOf(backend), s"$Root/emails") + assertEquals(pathOf(backend), s"$Endpoint/emails") assertEquals(bodyOf(backend), """{"emails":["maintainer@example.org"]}""") test("removing an address is retried, because it is idempotent by address"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.deleteEmails(Address).map(_ => assertEquals(attemptsOn(backend), 2)) @@ -141,7 +141,7 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.repositories(RepositoryOrder.Default, window(2, 20)).map: _ => - assertEquals(pathOf(backend), s"$Root/repos") + assertEquals(pathOf(backend), s"$Endpoint/repos") assertEquals(queryOf(backend), List("page" -> "2", "limit" -> "20")) test("a stated ordering is sent after the paging parameters"): @@ -166,12 +166,12 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.createRepository(CreateRepository.named(orFail(RepoName.from("codeberg4s")))).map: repository => assertEquals(methodOf(backend), "POST") - assertEquals(pathOf(backend), s"$Root/repos") + assertEquals(pathOf(backend), s"$Endpoint/repos") assertEquals(bodyOf(backend), """{"name":"codeberg4s","private":false,"template":false,"auto_init":false}""") assertEquals(repository.slug.name.value, "codeberg4s") test("creating a repository is never retried, because a repeat cannot be told apart from a name clash"): - val backend = RecordingBackend(failingThenSucceeding(201, UserAccountApiSuite.RepoBody)) + val backend = RecordingBackend(flakyThen(201, UserAccountApiSuite.RepoBody)) onApi(backend): api => api.attempt @@ -185,7 +185,7 @@ final class UserAccountApiSuite extends AccountApiSuite: onApi(backend): api => api.teams(PageParams.First).map: page => - assertEquals(pathOf(backend), s"$Root/teams") + assertEquals(pathOf(backend), s"$Endpoint/teams") assertEquals(queryOf(backend), List("page" -> "1", "limit" -> "30")) assertEquals(page.items.map(_.name), Vector("maintainers")) assertEquals(page.items.flatMap(_.organization).map(_.name.value), Vector("forgejo")) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserActionApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserActionApiSuite.scala index 9e8bf0a..5492cdb 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserActionApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserActionApiSuite.scala @@ -35,7 +35,7 @@ final class UserActionApiSuite extends AccountApiSuite: private val Variable: VariableName = orFail(VariableName.from("ENVIRONMENT")) - private val ActionsRoot: String = s"$Root/actions" + private val ActionsRoot: String = s"$Endpoint/actions" // --- runners -------------------------------------------------------------- @@ -78,7 +78,7 @@ final class UserActionApiSuite extends AccountApiSuite: assertEquals(registered.token.toString, RunnerRegistrationToken.Redacted) test("registering a runner is never retried, because a repeat registers a second one"): - val backend = RecordingBackend(failingThenSucceeding(201, UserActionApiSuite.RegisteredBody)) + val backend = RecordingBackend(flakyThen(201, UserActionApiSuite.RegisteredBody)) onApi(backend): api => api.attempt @@ -94,7 +94,7 @@ final class UserActionApiSuite extends AccountApiSuite: assertEquals(pathOf(backend), s"$ActionsRoot/runners/37") test("deleting a runner is retried, because a row id is never reused"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.deleteRunner(Runner).map(_ => assertEquals(attemptsOn(backend), 2)) @@ -138,13 +138,13 @@ final class UserActionApiSuite extends AccountApiSuite: assertEquals(bodyOf(backend), """{"data":"hunter2"}""") test("setting a secret is retried, because it is an assignment that ends in the requested state"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.setSecret(Secret, orFail(SecretValue.from("hunter2"))).map(_ => assertEquals(attemptsOn(backend), 2)) test("deleting a secret is never retried, because the name is reusable and the act is destructive"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.attempt.deleteSecret(Secret).map(_ => assertEquals(attemptsOn(backend), 1, "the DELETE was retried")) @@ -177,7 +177,7 @@ final class UserActionApiSuite extends AccountApiSuite: assertEquals(variable.value, "staging") test("creating a variable POSTs its value and is never retried"): - val backend = RecordingBackend(failingThenSucceeding(201, "")) + val backend = RecordingBackend(flakyThen(201, "")) onApi(backend): api => api.attempt @@ -193,13 +193,13 @@ final class UserActionApiSuite extends AccountApiSuite: assertEquals(bodyOf(backend), """{"value":"staging"}""") test("an update that only sets a value is retried, because it is an assignment"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.updateVariable(Variable, UpdateVariable.of("production")).map(_ => assertEquals(attemptsOn(backend), 2)) test("an update that renames is never retried, because a repeat addresses a name that is gone"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) val command = UpdateVariable.of("production").movedTo(orFail(VariableName.from("STAGE"))) onApi(backend): api => @@ -220,7 +220,7 @@ final class UserActionApiSuite extends AccountApiSuite: ) test("deleting a variable is never retried, for the reason deleting a secret is not"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.attempt.deleteVariable(Variable).map(_ => assertEquals(attemptsOn(backend), 1, "the DELETE was retried")) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserApplicationApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserApplicationApiSuite.scala index 45e4ac8..fa32671 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserApplicationApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserApplicationApiSuite.scala @@ -22,7 +22,7 @@ final class UserApplicationApiSuite extends AccountApiSuite: private val Application: OAuth2ApplicationId = orFail(OAuth2ApplicationId.from(7L)) - private val ApplicationsRoot: String = s"$Root/applications/oauth2" + private val ApplicationsRoot: String = s"$Endpoint/applications/oauth2" private def definition: OAuth2ApplicationDefinition = orFail(OAuth2ApplicationDefinition.named("deploy-bot")).redirectingTo("https://ci.example/cb").confidential @@ -83,7 +83,7 @@ final class UserApplicationApiSuite extends AccountApiSuite: assert(!application.toString.contains("gto_"), s"the generated toString leaked it: $application") test("creating an application is never retried, because a repeat mints a second unusable credential"): - val backend = RecordingBackend(failingThenSucceeding(201, UserApplicationApiSuite.CreatedBody)) + val backend = RecordingBackend(flakyThen(201, UserApplicationApiSuite.CreatedBody)) onApi(backend): api => api.attempt.create(definition).map(_ => assertEquals(attemptsOn(backend), 1, "the POST was retried")) @@ -101,7 +101,7 @@ final class UserApplicationApiSuite extends AccountApiSuite: ) test("updating an application is never retried, because a repeat may re-issue and invalidate a credential"): - val backend = RecordingBackend(failingThenSucceeding(200, UserApplicationApiSuite.ReadBody)) + val backend = RecordingBackend(flakyThen(200, UserApplicationApiSuite.ReadBody)) onApi(backend): api => api.attempt @@ -117,7 +117,7 @@ final class UserApplicationApiSuite extends AccountApiSuite: assertEquals(pathOf(backend), s"$ApplicationsRoot/7") test("deleting an application is retried, because a row id is never reused"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.delete(Application).map(_ => assertEquals(attemptsOn(backend), 2)) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserHookApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserHookApiSuite.scala index 3fef79c..5c07d3a 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserHookApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserHookApiSuite.scala @@ -28,7 +28,7 @@ final class UserHookApiSuite extends AccountApiSuite: private val Hook: HookId = orFail(HookId.from(11L)) - private val HooksRoot: String = s"$Root/hooks" + private val HooksRoot: String = s"$Endpoint/hooks" private def command: CreateHook = CreateHook @@ -80,7 +80,7 @@ final class UserHookApiSuite extends AccountApiSuite: ) test("creating a hook is never retried, because a repeat delivers every event twice"): - val backend = RecordingBackend(failingThenSucceeding(201, UserHookApiSuite.HookBody)) + val backend = RecordingBackend(flakyThen(201, UserHookApiSuite.HookBody)) onApi(backend): api => api.attempt.create(command).map(_ => assertEquals(attemptsOn(backend), 1, "the POST was retried")) @@ -102,7 +102,7 @@ final class UserHookApiSuite extends AccountApiSuite: assertEquals(bodyOf(backend), """{"active":false}""") test("editing a hook is retried, because it names a row id and states the value it wants"): - val backend = RecordingBackend(failingThenSucceeding(200, UserHookApiSuite.HookBody)) + val backend = RecordingBackend(flakyThen(200, UserHookApiSuite.HookBody)) onApi(backend): api => api.edit(Hook, EditHook.Empty.deactivated).map(_ => assertEquals(attemptsOn(backend), 2)) @@ -116,7 +116,7 @@ final class UserHookApiSuite extends AccountApiSuite: assertEquals(pathOf(backend), s"$HooksRoot/11") test("deleting a hook is retried, because a row id is never reused"): - val backend = RecordingBackend(failingThenSucceeding(204, "")) + val backend = RecordingBackend(flakyThen(204, "")) onApi(backend): api => api.delete(Hook).map(_ => assertEquals(attemptsOn(backend), 2)) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserQuotaApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserQuotaApiSuite.scala index 513f5f4..c0bcbd5 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserQuotaApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/account/UserQuotaApiSuite.scala @@ -18,7 +18,7 @@ final class UserQuotaApiSuite extends AccountApiSuite: private val Subject: QuotaSubject = orFail(QuotaSubject.from("size:repos:public")) - private val QuotaRoot: String = s"$Root/quota" + private val QuotaRoot: String = s"$Endpoint/quota" test("the report is read from the group's root path, with no query at all"): val backend = RecordingBackend(responding(200, UserQuotaApiSuite.ReportBody)) diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserKeyApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserKeyApiSuite.scala index 64589d9..df02311 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserKeyApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserKeyApiSuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.social +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.paging.PageParams @@ -21,7 +22,7 @@ import scala.concurrent.Future * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[UserKeyApi]]. */ -final class UserKeyApiSuite extends FunSuite with SocialApiHarness: +final class UserKeyApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Username = orFail(Username.from("earl-warren")) @@ -133,7 +134,7 @@ final class UserKeyApiSuite extends FunSuite with SocialApiHarness: assertEquals(token.value, "d3adb33f") test("a blank verification token is a decoding failure, not a token-shaped emptiness"): - onStub(responding(200, " ")): api => + onApi(responding(200, " ")): api => api.attempt.verificationToken().map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$") case other => fail(s"expected a decoding failure, got $other") @@ -154,7 +155,7 @@ final class UserKeyApiSuite extends FunSuite with SocialApiHarness: assertEquals(attemptsOn(backend), 1, "the POST was retried") test("a successful verification returns the key marked verified"): - onStub(responding(201, UserKeyApiSuite.VerifiedGpgKeyBody)): api => + onApi(responding(201, UserKeyApiSuite.VerifiedGpgKeyBody)): api => val token = orFail(GpgKeyToken.from("d3adb33f")) val claim = token.signedWith(orFail(OpenPgpKeyId.from("3AA5C34371567BD2")), orFail(ArmoredSignature.from("SIG"))) @@ -163,56 +164,54 @@ final class UserKeyApiSuite extends FunSuite with SocialApiHarness: // --- failures ------------------------------------------------------------- test("a 401 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(401, UserKeyApiSuite.UnauthorizedBody)): api => + onApi(responding(401, UserKeyApiSuite.UnauthorizedBody)): api => api.gpgKeys(PageParams.First).failed.map: case CodebergException(error) => assertEquals(summary(error), (UserKeyApi.GpgKeysOperation, 401, Some("token is required"))) case other => fail(s"expected a CodebergException, got $other") test("a 401 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(401, UserKeyApiSuite.UnauthorizedBody)): api => + onApi(responding(401, UserKeyApiSuite.UnauthorizedBody)): api => for raised <- api.gpgKeys(PageParams.First).failed typed <- api.attempt.gpgKeys(PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a unit-returning delete as well"): - onStub(responding(404, UserKeyApiSuite.NotFoundBody)): api => + onApi(responding(404, UserKeyApiSuite.NotFoundBody)): api => for raised <- api.deleteGpgKey(Gpg).failed typed <- api.attempt.deleteGpgKey(Gpg) yield assertRailsAgree(raised, typed) test("both rails agree on the plain-text endpoint too"): - onStub(responding(403, UserKeyApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserKeyApiSuite.ForbiddenBody)): api => for raised <- api.verificationToken().failed typed <- api.attempt.verificationToken() yield assertRailsAgree(raised, typed) test("a 422 is an Api failure, which is how a signature that did not verify arrives"): - onStub(responding(422, UserKeyApiSuite.ValidationBody)): api => + onApi(responding(422, UserKeyApiSuite.ValidationBody)): api => api.attempt.createGpgKey(orFail(CreateGpgKey.of("BLOCK"))).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 422) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """{"key_id": "3AA5C34371567BD2"}""")): api => + onApi(responding(200, """{"key_id": "3AA5C34371567BD2"}""")): api => api.attempt.gpgKey(Gpg).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$.id") case other => fail(s"expected a decoding failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, UserKeyApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserKeyApiSuite.ForbiddenBody)): api => api.attempt.deleteKey(Ssh).map(outcome => assertEquals(operationOf(outcome), UserKeyApi.DeleteKeyOperation)) // --- harness -------------------------------------------------------------- + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ private def onApi[A](backend: Backend[Future])(use: UserKeyApi => Future[A]): Future[A] = - onBackend(backend)(UserKeyApi(_))(use) - - private def onStub[A](backend: Backend[Future])(use: UserKeyApi => Future[A]): Future[A] = - onApi(backend)(use) + onPipeline(backend)(pipeline => use(UserKeyApi(pipeline))) /** The response bodies this suite stubs, hand-written from `spec/swagger.v1.json`; see the class note. */ object UserKeyApiSuite: diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala index 618e3b3..55f6c77 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserSocialApiSuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.social +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.Owner @@ -28,7 +29,7 @@ import java.time.LocalDate * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[UserSocialApi]]. */ -final class UserSocialApiSuite extends FunSuite with SocialApiHarness: +final class UserSocialApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Username = orFail(Username.from("earl-warren")) @@ -57,7 +58,7 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: assertEquals(page.items.map(_.login), Vector("earl-warren")) test("a listing ends where rel=next says it ends, not where a short page suggests"): - onStub(responding(200, UserSocialApiSuite.UserListBody, UserSocialApiSuite.PagedHeaders)): api => + onApi(responding(200, UserSocialApiSuite.UserListBody, UserSocialApiSuite.PagedHeaders)): api => api.followers(window(1, 30)).map: page => assertEquals(page.size, 1) assertEquals(page.totalCount, Some(97)) @@ -65,7 +66,7 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: assertEquals(page.isLast, false) test("a page past the end is an empty page, not a failure"): - onStub(responding(200, "[]")): api => + onApi(responding(200, "[]")): api => api.following(PageParams.First).map(page => assertEquals(page.isLast, true)) test("following an account is a PUT with no body"): @@ -102,7 +103,7 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: assertEquals(following, true) test("a 404 from the follow check means no, and is not a failure on either rail"): - onStub(responding(404, UserSocialApiSuite.NotFoundBody)): api => + onApi(responding(404, UserSocialApiSuite.NotFoundBody)): api => for direct <- api.isFollowing(Target) typed <- api.attempt.isFollowing(Target) @@ -111,7 +112,7 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: assertEquals(typed, Right(false)) test("a 403 from the follow check is still a failure, so an unreadable account is not a negative answer"): - onStub(responding(403, UserSocialApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserSocialApiSuite.ForbiddenBody)): api => api.attempt.isFollowing(Target).map: outcome => assert(outcome.isLeft, s"a 403 was reported as 'not following': $outcome") @@ -172,8 +173,8 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: test("the star check answers yes on 204 and no on 404"): for - yes <- onStub(responding(204, ""))(_.isStarred(Repo, Name)) - no <- onStub(responding(404, UserSocialApiSuite.NotFoundBody))(_.isStarred(Repo, Name)) + yes <- onApi(responding(204, ""))(_.isStarred(Repo, Name)) + no <- onApi(responding(404, UserSocialApiSuite.NotFoundBody))(_.isStarred(Repo, Name)) yield assertEquals(yes, true) assertEquals(no, false) @@ -275,56 +276,54 @@ final class UserSocialApiSuite extends FunSuite with SocialApiHarness: // --- failures ------------------------------------------------------------- test("a 403 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(403, UserSocialApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserSocialApiSuite.ForbiddenBody)): api => api.followers(PageParams.First).failed.map: case CodebergException(error) => assertEquals(summary(error), (UserSocialApi.FollowersOperation, 403, Some(UserSocialApiSuite.ForbiddenText))) case other => fail(s"expected a CodebergException, got $other") test("a 403 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(403, UserSocialApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserSocialApiSuite.ForbiddenBody)): api => for raised <- api.followers(PageParams.First).failed typed <- api.attempt.followers(PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on a unit-returning write as well, so the choice of rail is only a choice of style"): - onStub(responding(403, UserSocialApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserSocialApiSuite.ForbiddenBody)): api => for raised <- api.block(Target).failed typed <- api.attempt.block(Target) yield assertRailsAgree(raised, typed) test("both rails agree on the status-only check too"): - onStub(responding(401, UserSocialApiSuite.UnauthorizedBody)): api => + onApi(responding(401, UserSocialApiSuite.UnauthorizedBody)): api => for raised <- api.isStarred(Repo, Name).failed typed <- api.attempt.isStarred(Repo, Name) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation alongside 422"): - onStub(responding(400, UserSocialApiSuite.ValidationBody)): api => + onApi(responding(400, UserSocialApiSuite.ValidationBody)): api => api.attempt.blocked(PageParams.First).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a 200 whose payload does not fit the model becomes DecodingFailed, never an escaping codec exception"): - onStub(responding(200, """[{"created_at": null}]""")): api => + onApi(responding(200, """[{"created_at": null}]""")): api => api.attempt.blocked(PageParams.First).map: case Left(CodebergError.DecodingFailed(_, _, path, _)) => assertEquals(path.render, "$[0].block_id") case other => fail(s"expected a decoding failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, UserSocialApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserSocialApiSuite.ForbiddenBody)): api => api.attempt.heatmap(Handle).map(outcome => assertEquals(operationOf(outcome), UserSocialApi.HeatmapOperation)) // --- harness -------------------------------------------------------------- + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ private def onApi[A](backend: Backend[Future])(use: UserSocialApi => Future[A]): Future[A] = - onBackend(backend)(UserSocialApi(_))(use) - - private def onStub[A](backend: Backend[Future])(use: UserSocialApi => Future[A]): Future[A] = - onApi(backend)(use) + onPipeline(backend)(pipeline => use(UserSocialApi(pipeline))) /** The response bodies this suite stubs, kept out of the test bodies so each test reads as one behaviour. * diff --git a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala index a9afbe3..160043b 100644 --- a/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala +++ b/modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala @@ -1,5 +1,6 @@ package com.worxbend.codeberg4s.users.social +import com.worxbend.codeberg4s.ClientSuiteHarness import com.worxbend.codeberg4s.CodebergError import com.worxbend.codeberg4s.CodebergException import com.worxbend.codeberg4s.HttpMethod @@ -29,7 +30,7 @@ import scala.concurrent.Future * '''No golden fixture backs this group.''' Every payload below was written from `spec/swagger.v1.json`; see the class * note on [[UserTokenApi]]. */ -final class UserTokenApiSuite extends FunSuite with SocialApiHarness: +final class UserTokenApiSuite extends FunSuite with ClientSuiteHarness: private val Handle: Username = orFail(Username.from("earl-warren")) @@ -51,7 +52,7 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: assertEquals(page.items.flatMap(_.lastEight), Vector("edential")) test("the listing model cannot carry a credential even when the instance sends one"): - onStub(responding(200, s"[${UserTokenApiSuite.CreatedBody}]")): api => + onApi(responding(200, s"[${UserTokenApiSuite.CreatedBody}]")): api => api.list(Handle, PageParams.First).map: page => assert( !page.items.toString.contains(UserTokenApiSuite.Material), @@ -78,7 +79,7 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: assertEquals(created.details.id.value, 42L) test("a minted token never renders its material, from any of the three rendering paths"): - onStub(responding(201, UserTokenApiSuite.CreatedBody)): api => + onApi(responding(201, UserTokenApiSuite.CreatedBody)): api => api.create(Handle, orFail(CreateAccessToken.named("ci"))).map: created => assert(!created.toString.contains(UserTokenApiSuite.Material), s"the token reached toString: $created") assert(!s"$created".contains(UserTokenApiSuite.Material), "the token reached string interpolation") @@ -107,7 +108,7 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: assertEquals(attemptsOn(backend), 1, "the POST was retried") test("a failed creation puts nothing token-shaped into the error a caller would log"): - onStub(responding(403, UserTokenApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserTokenApiSuite.ForbiddenBody)): api => api.attempt .create(Handle, orFail(CreateAccessToken.named("ci"))) .map: @@ -117,7 +118,7 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: case Right(_) => fail("expected a 403 to fail") test("a creation whose payload carried a credential but no id fails without echoing the body"): - onStub(responding(201, UserTokenApiSuite.CreatedWithoutIdBody)): api => + onApi(responding(201, UserTokenApiSuite.CreatedWithoutIdBody)): api => api.attempt.create(Handle, orFail(CreateAccessToken.named("ci"))).map: case Left(error @ CodebergError.DecodingFailed(_, snippet, path, _)) => assertEquals(path.render, "$.id") @@ -130,14 +131,14 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: fail(s"expected a decoding failure, got $other") test("the placeholder reaches the convenience rail too, not only the typed one"): - onStub(responding(201, UserTokenApiSuite.CreatedWithoutIdBody)): api => + onApi(responding(201, UserTokenApiSuite.CreatedWithoutIdBody)): api => api.create(Handle, orFail(CreateAccessToken.named("ci"))).failed.map: case CodebergException(error) => assert(!error.describe.contains(UserTokenApiSuite.Material), s"the credential reached: ${error.describe}") case other => fail(s"expected a CodebergException, got $other") test("every other call keeps the body excerpt, because only the token endpoint's body is a credential"): - onStub(responding(200, UserTokenApiSuite.MalformedListBody)): api => + onApi(responding(200, UserTokenApiSuite.MalformedListBody)): api => api.attempt.list(Handle, PageParams.First).map: case Left(CodebergError.DecodingFailed(_, snippet, _, _)) => assertEquals(snippet, UserTokenApiSuite.MalformedListBody) @@ -172,41 +173,41 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: // --- failures ------------------------------------------------------------- test("a 403 fails the convenience rail with a CodebergException carrying the Api failure"): - onStub(responding(403, UserTokenApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserTokenApiSuite.ForbiddenBody)): api => api.list(Handle, PageParams.First).failed.map: case CodebergException(error) => assertEquals(summary(error), (UserTokenApi.ListOperation, 403, Some(UserTokenApiSuite.ForbiddenText))) case other => fail(s"expected a CodebergException, got $other") test("a 403 reaches the typed rail as a Left reporting the very same failure"): - onStub(responding(403, UserTokenApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserTokenApiSuite.ForbiddenBody)): api => for raised <- api.list(Handle, PageParams.First).failed typed <- api.attempt.list(Handle, PageParams.First) yield assertRailsAgree(raised, typed) test("both rails agree on the creation as well, credential or no credential"): - onStub(responding(403, UserTokenApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserTokenApiSuite.ForbiddenBody)): api => for raised <- api.create(Handle, orFail(CreateAccessToken.named("ci"))).failed typed <- api.attempt.create(Handle, orFail(CreateAccessToken.named("ci"))) yield assertRailsAgree(raised, typed) test("both rails agree on the revocation as well"): - onStub(responding(404, UserTokenApiSuite.NotFoundBody)): api => + onApi(responding(404, UserTokenApiSuite.NotFoundBody)): api => for raised <- api.delete(Handle, ById).failed typed <- api.attempt.delete(Handle, ById) yield assertRailsAgree(raised, typed) test("a 400 is an Api failure too — Forgejo uses it for validation on this endpoint"): - onStub(responding(400, UserTokenApiSuite.ValidationBody)): api => + onApi(responding(400, UserTokenApiSuite.ValidationBody)): api => api.attempt.create(Handle, orFail(CreateAccessToken.named("ci"))).map: case Left(CodebergError.Api(_, status, _)) => assertEquals(status, 400) case other => fail(s"expected an Api failure, got $other") test("a failure carries the operation id of the endpoint it came from, so an alert can name it"): - onStub(responding(403, UserTokenApiSuite.ForbiddenBody)): api => + onApi(responding(403, UserTokenApiSuite.ForbiddenBody)): api => api.attempt .delete(Handle, ById) .map(outcome => assertEquals(operationOf(outcome), UserTokenApi.DeleteOperation)) @@ -216,11 +217,9 @@ final class UserTokenApiSuite extends FunSuite with SocialApiHarness: private def slug: RepoSlug = RepoSlug(orFail(Owner.from("forgejo")), orFail(RepoName.from("forgejo"))) + /** Builds the API under test on a pipeline over `backend`, releasing the timer whatever happens. */ private def onApi[A](backend: Backend[Future])(use: UserTokenApi => Future[A]): Future[A] = - onBackend(backend)(UserTokenApi(_))(use) - - private def onStub[A](backend: Backend[Future])(use: UserTokenApi => Future[A]): Future[A] = - onApi(backend)(use) + onPipeline(backend)(pipeline => use(UserTokenApi(pipeline))) /** The response bodies this suite stubs, hand-written from `spec/swagger.v1.json`; see the class note. */ object UserTokenApiSuite: From 35683f541fd21c26de7a08a2dd2b2be9aac85f1c Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 18:04:06 +0300 Subject: [PATCH 29/31] fix(build): exclude the Property tag from every unit-gate module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify.sh` chained the five unit test modules with Mill's `+` separator and wrote `--exclude-tags=Property` once, after the last one. Mill scopes the arguments that follow a target to that target alone, so only the last module in the chain honoured the exclusion: `domain`, `core`, `codec` and `transport` ran their ScalaCheck suites inside the routine gate. Nothing went red, which is why it went unnoticed — the properties pass. It simply meant the gate was slower than intended and that property runs were not separated from routine verification the way `docs/CONSTITUTION_MAPPING.md` requires. The flag is now repeated per target, and a check after the run asserts the exclusion took effect: an excluded suite reports a total of zero, so any `*Props` suite finishing with a non-zero total is a property suite that leaked back into the gate, and the run fails naming it. --- verify.sh | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/verify.sh b/verify.sh index 67e9e6f..283ba9b 100755 --- a/verify.sh +++ b/verify.sh @@ -173,10 +173,17 @@ announce "Unit tests (excluding the Property tag and modules/it)" # suite reports "1 ignored, 0 total" and the run still exits 0. That is a # false-green gate, so the separator is load-bearing and the assertion below # exists to make sure a future edit cannot reintroduce it. +# +# --exclude-tags is repeated per module for the same reason it is not written +# once at the end: Mill scopes the arguments after a target to THAT target only, +# so a single trailing `--exclude-tags=Property` reached the last module in the +# chain and no other. Every earlier module then ran its ScalaCheck suites inside +# the routine gate — slow, and precisely the separation docs/CONSTITUTION_MAPPING.md +# requires. The check after the run asserts the exclusion actually took effect. test_targets=() for target in "${UNIT_MODULES[@]}"; do [[ ${#test_targets[@]} -eq 0 ]] || test_targets+=("+") - test_targets+=("$target") + test_targets+=("$target" --exclude-tags=Property) done test_log="$work_dir/tests.log" @@ -190,7 +197,7 @@ test_log="$work_dir/tests.log" # skipped exactly the suites that failed — undercounting the total AND reading # zero failures. Strip first, then count. set -o pipefail -"$MILL" "${test_targets[@]}" --exclude-tags=Property 2>&1 | +"$MILL" "${test_targets[@]}" 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tee "$test_log" test_status=${PIPESTATUS[0]} set +o pipefail @@ -206,6 +213,21 @@ if [[ "$executed" -lt 100 ]]; then fi echo " $executed tests executed" +# An excluded suite still reports a line, with everything ignored and a total of +# zero. So a `*Props` suite with a non-zero total is a property suite that ran +# inside the routine gate — which is what a mis-scoped --exclude-tags looks like +# from the outside, and it is silent otherwise because the properties pass. +leaked=$(grep -oE 'Test run [A-Za-z0-9_.]*Props finished: [0-9]+ failed, [0-9]+ ignored, [0-9]+ total' "$test_log" | + awk '$(NF - 1) != 0 { print $3 }' | sort -u) +if [[ -n "$leaked" ]]; then + printf '\033[31m property suites ran inside the unit gate:\033[0m\n' >&2 + printf ' %s\n' "$leaked" >&2 + printf ' Each module in the chain needs its own --exclude-tags=Property; Mill\n' >&2 + printf ' applies a trailing one to the last target only.\n' >&2 + fail "the Property exclusion did not take effect" +fi +echo " no property suite ran inside the gate" + # --------------------------------------------------------------------------- announce "Architecture boundary check" # These are the reviewer's veto list from PLAN.md §8, cheap enough to automate. From 29a7e9408ec456865672b6bf30fff0d3e4e7741b Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 18:04:14 +0300 Subject: [PATCH 30/31] test: tag every property automatically in PropertyBase Membership of the `Property` tag decided whether a suite ran in the routine gate, and it rested on each author remembering to write `.tag(Property)` on every declaration. All three PropertyBase scaladocs admitted as much: a property written without the tag silently rejoined the default gate, and nothing would report it. Each trait now overrides `munitTests()` to add the tag to every test the subclass declares. munit builds the test list first and filters by tag afterwards, so this is equivalent to tagging each declaration by hand, except that it cannot be forgotten. Tags are a `Set`, so the explicit tags already written stay valid and simply become redundant. --- .../codeberg4s/codec/PropertyBaseProps.scala | 13 +++++++++++-- .../codeberg4s/core/PropertyBaseProps.scala | 13 +++++++++++-- .../com/worxbend/codeberg4s/PropertyBaseProps.scala | 13 +++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/modules/codec/test/src/com/worxbend/codeberg4s/codec/PropertyBaseProps.scala b/modules/codec/test/src/com/worxbend/codeberg4s/codec/PropertyBaseProps.scala index a0d5418..fa4a75b 100644 --- a/modules/codec/test/src/com/worxbend/codeberg4s/codec/PropertyBaseProps.scala +++ b/modules/codec/test/src/com/worxbend/codeberg4s/codec/PropertyBaseProps.scala @@ -10,8 +10,9 @@ import org.scalacheck.rng.Seed * * '''The tag.''' Every property in this module carries [[Property]], whose value is the exact string `"Property"`. * `verify.sh` runs the unit gate with `--exclude-tags=Property` and munit's tag filter compares against the tag's - * value, so any other spelling silently leaves these suites inside the routine run. Nothing enforces this - * mechanically: a property written without `.tag(Property)` rejoins the default gate. + * value, so any other spelling silently leaves these suites inside the routine run. The tag is applied by + * [[munitTests]] below rather than trusted to every author, so a property written without `.tag(Property)` is still + * excluded; the explicit tags kept on the existing properties are redundant with that override, not load-bearing. * * '''The seed.''' Pinned, so a counterexample found on one machine is reproducible on the next. Test modules do not * share code, so this trait is a near-copy of the ones in `domain` and `core`; that is the build's structure rather @@ -22,6 +23,14 @@ trait PropertyBase extends ScalaCheckSuite: /** The tag excluded by `verify.sh`. Put it on every property in this module. */ protected val Property: Tag = Tag(PropertyBase.TagName) + /** Tags every test declared in a subclass with [[Property]], whether or not its author remembered to. + * + * munit builds the suite's test list first and filters it by tag afterwards, so adding the tag here is equivalent to + * writing `.tag(Property)` on each declaration — and unlike the convention, it cannot be forgotten. `tags` is a + * `Set`, so re-tagging an already-tagged property changes nothing. + */ + override def munitTests(): Seq[munit.Test] = super.munitTests().map(_.tag(Property)) + override def scalaCheckInitialSeed: String = Seed(PropertyBase.SeedValue).toBase64 override def scalaCheckTestParameters: Test.Parameters = diff --git a/modules/core/test/src/com/worxbend/codeberg4s/core/PropertyBaseProps.scala b/modules/core/test/src/com/worxbend/codeberg4s/core/PropertyBaseProps.scala index 0e1b604..2a25ca7 100644 --- a/modules/core/test/src/com/worxbend/codeberg4s/core/PropertyBaseProps.scala +++ b/modules/core/test/src/com/worxbend/codeberg4s/core/PropertyBaseProps.scala @@ -10,8 +10,9 @@ import org.scalacheck.rng.Seed * * '''The tag.''' Every property in this module carries [[Property]], whose value is the exact string `"Property"`. * `verify.sh` runs the unit gate with `--exclude-tags=Property` and munit's tag filter compares against the tag's - * value, so any other spelling silently leaves these suites inside the routine run. Nothing enforces this - * mechanically: a property written without `.tag(Property)` rejoins the default gate. + * value, so any other spelling silently leaves these suites inside the routine run. The tag is applied by + * [[munitTests]] below rather than trusted to every author, so a property written without `.tag(Property)` is still + * excluded; the explicit tags kept on the existing properties are redundant with that override, not load-bearing. * * '''The seed.''' Pinned, so a counterexample found on one machine is reproducible on the next. The module's test * modules do not share code, so this trait is a near-copy of the one in `domain`; that is the build's structure, not @@ -22,6 +23,14 @@ trait PropertyBase extends ScalaCheckSuite: /** The tag excluded by `verify.sh`. Put it on every property in this module. */ protected val Property: Tag = Tag(PropertyBase.TagName) + /** Tags every test declared in a subclass with [[Property]], whether or not its author remembered to. + * + * munit builds the suite's test list first and filters it by tag afterwards, so adding the tag here is equivalent to + * writing `.tag(Property)` on each declaration — and unlike the convention, it cannot be forgotten. `tags` is a + * `Set`, so re-tagging an already-tagged property changes nothing. + */ + override def munitTests(): Seq[munit.Test] = super.munitTests().map(_.tag(Property)) + override def scalaCheckInitialSeed: String = Seed(PropertyBase.SeedValue).toBase64 override def scalaCheckTestParameters: Test.Parameters = diff --git a/modules/domain/test/src/com/worxbend/codeberg4s/PropertyBaseProps.scala b/modules/domain/test/src/com/worxbend/codeberg4s/PropertyBaseProps.scala index a43c01c..062e14e 100644 --- a/modules/domain/test/src/com/worxbend/codeberg4s/PropertyBaseProps.scala +++ b/modules/domain/test/src/com/worxbend/codeberg4s/PropertyBaseProps.scala @@ -11,8 +11,9 @@ import org.scalacheck.rng.Seed * '''The tag.''' Every property in this module carries [[Property]], whose value is the exact string `"Property"`. * That spelling is load-bearing: `verify.sh` runs the unit gate with `--exclude-tags=Property`, and munit's tag filter * compares against the tag's value, so a tag spelled anything else leaves the property suites inside the routine run — - * which is precisely what `docs/CONSTITUTION_MAPPING.md` says must not happen. Nothing enforces the tag mechanically: - * a property written without `.tag(Property)` silently rejoins the default gate. + * which is precisely what `docs/CONSTITUTION_MAPPING.md` says must not happen. The tag is applied by [[munitTests]] + * below rather than trusted to every author, so a property written without `.tag(Property)` is still excluded; the + * explicit tags kept on the existing properties are redundant with that override, not load-bearing. * * '''The seed.''' `scalaCheckInitialSeed` is pinned, so every run explores the same values in the same order. * ScalaCheck's own default seeds from the system clock, which turns a genuine counterexample into a flake that the @@ -23,6 +24,14 @@ trait PropertyBase extends ScalaCheckSuite: /** The tag excluded by `verify.sh`. Put it on every property in this module. */ protected val Property: Tag = Tag(PropertyBase.TagName) + /** Tags every test declared in a subclass with [[Property]], whether or not its author remembered to. + * + * munit builds the suite's test list first and filters it by tag afterwards, so adding the tag here is equivalent to + * writing `.tag(Property)` on each declaration — and unlike the convention, it cannot be forgotten. `tags` is a + * `Set`, so re-tagging an already-tagged property changes nothing. + */ + override def munitTests(): Seq[munit.Test] = super.munitTests().map(_.tag(Property)) + override def scalaCheckInitialSeed: String = Seed(PropertyBase.SeedValue).toBase64 /** munit's own default is ten successful cases per property, which is too few to reach the interesting corners of the From a4da84bb9ed1595658aa5b3faae74b4ddde98fe3 Mon Sep 17 00:00:00 2001 From: w0rxbend Date: Mon, 31 Aug 2026 22:04:31 +0300 Subject: [PATCH 31/31] docs(site): import Owner and RepoName from the package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pages still imported `Owner` and `RepoName` from the `com.worxbend.codeberg4s.repositories` sub-package, where they no longer live. Every Scala block on this site is compiled by mdoc against the real library as part of the site build, so this was not a cosmetic staleness: it failed the build with nineteen "Not found: type Owner" errors. The landing page's quick start now uses a single `import com.worxbend.codeberg4s.*`. That is the point of the root re-exports — `CodebergClient`, `CodebergConfig`, `Auth`, `Owner` and `RepoName` all resolve from one line, where the page previously needed three. Getting Started keeps its imports written out one type at a time, because a page whose job is to show a beginner where each name comes from should not hide that behind a wildcard; there, `Owner` and `RepoName` simply moved onto the existing root import line and `Repository` stays imported from `repositories`, which is still its package. --- site/src/getting-started.md | 18 ++++++++---------- site/src/landing-page.md | 4 +--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/site/src/getting-started.md b/site/src/getting-started.md index 6e0349f..7988b9a 100644 --- a/site/src/getting-started.md +++ b/site/src/getting-started.md @@ -60,8 +60,8 @@ Identifiers are opaque types with `Either`-returning smart constructors. a `/` would otherwise forge a request path. ```scala mdoc:compile-only -import com.worxbend.codeberg4s.{CodebergClient, ValidationError} -import com.worxbend.codeberg4s.repositories.{Owner, RepoName, Repository} +import com.worxbend.codeberg4s.{CodebergClient, Owner, RepoName, ValidationError} +import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Future @@ -118,8 +118,8 @@ The convenience rail fails the `Future` with a `CodebergException`, which carries the full `CodebergError`, so nothing is lost by using it: ```scala mdoc:compile-only -import com.worxbend.codeberg4s.{CodebergClient, CodebergError, CodebergException} -import com.worxbend.codeberg4s.repositories.{Owner, RepoName, Repository} +import com.worxbend.codeberg4s.{CodebergClient, CodebergError, CodebergException, Owner, RepoName} +import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.{ExecutionContext, Future} @@ -138,8 +138,8 @@ Any case you do not handle stays a failed `Future`, carrying the same value. The typed rail never fails the `Future`: ```scala mdoc:compile-only -import com.worxbend.codeberg4s.{CodebergClient, CodebergError} -import com.worxbend.codeberg4s.repositories.{Owner, RepoName, Repository} +import com.worxbend.codeberg4s.{CodebergClient, CodebergError, Owner, RepoName} +import com.worxbend.codeberg4s.repositories.Repository import scala.concurrent.Future @@ -163,10 +163,9 @@ mistake made against this library. Listings take a `PageParams` and return one `Page[A]`: ```scala mdoc:compile-only -import com.worxbend.codeberg4s.CodebergClient +import com.worxbend.codeberg4s.{CodebergClient, Owner, RepoName} import com.worxbend.codeberg4s.issues.{Issue, IssueQuery} import com.worxbend.codeberg4s.paging.{Page, PageParams} -import com.worxbend.codeberg4s.repositories.{Owner, RepoName} import scala.concurrent.Future @@ -189,10 +188,9 @@ A `Page[A]` carries `items`, the `params` that produced it, an optional `modules/core`, so a caller writes the walk by hand. Nine lines: ```scala mdoc:compile-only -import com.worxbend.codeberg4s.CodebergClient +import com.worxbend.codeberg4s.{CodebergClient, Owner, RepoName} import com.worxbend.codeberg4s.issues.{Issue, IssueQuery} import com.worxbend.codeberg4s.paging.PageParams -import com.worxbend.codeberg4s.repositories.{Owner, RepoName} import scala.concurrent.{ExecutionContext, Future} diff --git a/site/src/landing-page.md b/site/src/landing-page.md index ed97982..ce7d7b6 100644 --- a/site/src/landing-page.md +++ b/site/src/landing-page.md @@ -38,9 +38,7 @@ items came back. That distinction is not pedantry; see ## Quick start ```scala mdoc:compile-only -import com.worxbend.codeberg4s.{CodebergClient, CodebergConfig} -import com.worxbend.codeberg4s.auth.Auth -import com.worxbend.codeberg4s.repositories.{Owner, RepoName} +import com.worxbend.codeberg4s.* import scala.concurrent.ExecutionContext.Implicits.global val client = CodebergClient(CodebergConfig(Auth.Anonymous))