A Scala 3 client for the Codeberg / Forgejo REST API v1.
Future-based public API — no effect system leaks into your code, and no effect system is added to your classpath.- Works against any Forgejo or Gitea-compatible instance. Codeberg is the default base URI, not a hardcoded one.
- Two error rails. Use exceptions if that suits your codebase, or typed
Eithervalues if it does not. Same implementation underneath. - Illegal requests are unrepresentable. Owners, repository names, branches,
labels and page sizes are validated types with
Either-returning constructors, so a value that would forge a request path is rejected before a client is involved. - Pagination you cannot get wrong by accident. No operation returns an
unbounded
List; every listing hands back aPage[A]that says whether another page exists. - Small dependency footprint — sttp client4 and jsoniter-scala. That is the list.
Status: pre-release,
0.1.0in progress. All 439 in-scope operations are implemented on both rails — the whole Forgejo v1 API exceptadmin,activitypubandpackage, whichPLAN.mdputs out of scope for v1.docs/ROADMAP.mdtracks phases;docs/API_INVENTORY.mdhas the endpoint-level checklist and the honest percentage. Nothing is published to Maven Central yet — see "Install".
Nothing is published to Maven Central yet. The build is configured for it —
five artifacts under com.worxbend, currently at 0.1.0-SNAPSHOT — but until
the 0.1.0 tag is cut these coordinates resolve only against a local publish.
// Mill
def mvnDeps = Seq(mvn"com.worxbend::codeberg4s-client:0.1.0")
// sbt
libraryDependencies += "com.worxbend" %% "codeberg4s-client" % "0.1.0"codeberg4s-client pulls in -transport, -codec, -core and -domain
transitively. Depend on a narrower one if you want less: codeberg4s-domain is
the models and the error ADT with no dependencies at all, which is enough to
write code that handles a CodebergError without linking a HTTP client.
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 scala.concurrent.ExecutionContext
import scala.concurrent.Future
given ExecutionContext = ExecutionContext.global
// The client owns an HTTP connection pool and a scheduler thread.
// Build one per instance you talk to, for the lifetime of the application.
val client: CodebergClient = CodebergClient(CodebergConfig(Auth.Anonymous))
val stars: Either[ValidationError, Future[Long]] =
for
owner <- Owner.from("forgejo")
name <- RepoName.from("forgejo")
yield client.repos.get(owner, name).map(_.starsCount)
// ... and at shutdown:
client.close()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:
import com.worxbend.codeberg4s.CodebergConfig
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.auth.ApiToken
import com.worxbend.codeberg4s.auth.Auth
val config: Either[ValidationError, CodebergConfig] =
ApiToken
.from(sys.env.getOrElse("CODEBERG_TOKEN", ""))
.map(token => CodebergConfig(Auth.Token(token)))CodebergConfig(auth) fills in Codeberg's base URI, the default retry policy,
the default user agent, a page size of 30 and 10 s / 30 s timeouts. Copy the
result to change one field — see Configuration.
Everything is grouped the way the API's own tags are.
| Accessor | Group |
|---|---|
client.version |
GET /version — what software the instance runs |
client.repos |
repositories, branches, tags, commits, releases, topics, forks, contents |
client.users |
the current account, accounts by name, search, follows, keys |
client.issues |
issues, comments, labels, milestones |
client.pulls |
pull requests, merge, reviews, commits, changed files |
client.organizations |
organisations, teams, membership |
client.notifications |
the notification inbox, per-thread and per-repository |
client.misc |
markdown rendering, instance settings, the signing key |
The examples in this section all assume the following are in scope:
import com.worxbend.codeberg4s.CodebergClient
import com.worxbend.codeberg4s.repositories.Owner
import com.worxbend.codeberg4s.repositories.RepoName
import scala.concurrent.ExecutionContext
given ExecutionContext = ExecutionContext.global
def client: CodebergClient
def owner: Owner // Owner.from("forgejo")
def name: RepoName // RepoName.from("forgejo")import com.worxbend.codeberg4s.ServerVersion
import scala.concurrent.Future
val version: Future[String] = client.version.get().map((v: ServerVersion) => v.raw)Useful as a liveness probe: it is the one endpoint every Forgejo answers anonymously.
Eleven operations. Listings take a PageParams and return one Page.
import com.worxbend.codeberg4s.paging.PageParams
import com.worxbend.codeberg4s.repositories.Release
import scala.concurrent.Future
val latestTags: Future[Vector[String]] =
client.repos
.listReleases(owner, name, PageParams.First)
.map(page => page.items.map((release: Release) => release.tagName.value))getContents is the one union in the API: the same path returns a file object
or an array of directory entries, so it decodes to an ADT rather than to a
nullable record. docs/HAZARDS.md §3 has the captured payloads.
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.repositories.ContentEntry
import com.worxbend.codeberg4s.repositories.ContentPath
import com.worxbend.codeberg4s.repositories.RepositoryContent
import scala.concurrent.Future
val readme: Either[ValidationError, Future[Option[String]]] =
ContentPath
.from("README.md")
.map: path =>
client.repos.getContents(owner, name, path).map:
case RepositoryContent.File(ContentEntry.File(_, content, _)) => content.flatMap(_.text)
case RepositoryContent.File(_) => None
case RepositoryContent.Directory(_) => NoneEight operations, in two families. /user/… means "whoever the configured
credentials are" and needs a token; /users/{username}/… names an account.
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.paging.Page
import com.worxbend.codeberg4s.paging.PageParams
import com.worxbend.codeberg4s.repositories.Repository
import com.worxbend.codeberg4s.users.Username
import scala.concurrent.Future
val me: Future[String] = client.users.current().map(_.login)
val theirRepos: Either[ValidationError, Future[Page[Repository]]] =
Username.from("earl-warren").map(who => client.users.repositories(who, PageParams.First))Do not assume the anonymous paths are anonymous. The pinned spec carries no
per-endpoint security information at all, and codeberg.org answers 401 to an
anonymous GET /users/{username}/followers — measured, not guessed
(docs/HAZARDS.md §2).
Ten operations. Filters are a value, not a pile of Option parameters:
import com.worxbend.codeberg4s.issues.IssueQuery
import com.worxbend.codeberg4s.issues.StateFilter
import com.worxbend.codeberg4s.paging.PageParams
import scala.concurrent.Future
val query: IssueQuery = IssueQuery.Empty.withState(StateFilter.Open).authoredBy("earl-warren")
val titles: Future[Vector[String]] =
client.issues.list(owner, name, query, PageParams.First).map(page => page.items.map(_.title))Creating goes through a validated command, so an empty title is a
ValidationError rather than a 422 from the server:
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.issues.CreateIssue
import com.worxbend.codeberg4s.issues.Issue
import scala.concurrent.Future
val filed: Either[ValidationError, Future[Issue]] =
CreateIssue
.of("Retry storm on 429")
.map(_.withBody("Backoff ignores Retry-After when the header is a date."))
.map(command => client.issues.create(owner, name, command))An issue's state is LifecycleState, an ADT — Closed carries the closing
timestamp, so "closed" and "when" cannot get out of step.
Eight operations. head is PullRequestHead, which is either a branch in this
repository or the owner:branch form a fork needs; there is no way to pass one
where the other was meant:
import com.worxbend.codeberg4s.ValidationError
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 scala.concurrent.Future
val opened: Either[ValidationError, Future[PullRequest]] =
for
from <- BranchName.from("feature/stream-pages").map(PullRequestHead.branch)
into <- BranchName.from("main")
command <- CreatePullRequest.of("Stream pages instead of buffering", from, into)
yield client.pulls.create(owner, name, command)Merging is a command too, and merge returns Future[Unit]: Forgejo answers
200 with no body, and inventing a PullRequest to return would mean guessing
at post-merge state the server did not send.
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.pulls.MergePullRequest
import com.worxbend.codeberg4s.pulls.MergeStyle
import com.worxbend.codeberg4s.pulls.PullRequestNumber
import scala.concurrent.Future
val merged: Either[ValidationError, Future[Unit]] =
PullRequestNumber
.from(1234L)
.map: number =>
client.pulls.merge(
owner,
name,
number,
MergePullRequest.using(MergeStyle.Squash).deletingSourceBranch,
)PullRequestState is Open | Closed | Merged, folded from Forgejo's state
string and its separate merged boolean. Reading a merged pull request as
merely closed is the bug that shape prevents.
Ten operations, covering organisations, their repositories and members, and
teams. Teams are rooted at /teams/{id} rather than under the organisation,
which is why getTeam takes only an id:
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.organizations.OrgName
import com.worxbend.codeberg4s.organizations.Team
import com.worxbend.codeberg4s.paging.PageParams
import scala.concurrent.Future
val teamNames: Either[ValidationError, Future[Vector[String]]] =
OrgName
.from("forgejo")
.map: org =>
client.organizations
.teams(org, PageParams.First)
.map(page => page.items.map((team: Team) => team.name))A team's permission is TeamPermission, ordered, so authorisation checks read
as permission.allows(TeamPermission.Write) rather than as string comparison.
Seven operations. All of them require a token — there is no anonymous inbox.
import com.worxbend.codeberg4s.notifications.NotificationQuery
import com.worxbend.codeberg4s.notifications.NotificationSubjectFilter
import com.worxbend.codeberg4s.notifications.NotificationThread
import com.worxbend.codeberg4s.paging.PageParams
import scala.concurrent.Future
val unread: Future[Long] = client.notifications.unreadCount().map(_.value)
val pullThreads: Future[Vector[NotificationThread]] =
client.notifications
.list(
NotificationQuery.Empty.withSubjects(Vector(NotificationSubjectFilter.Pull)),
PageParams.First,
)
.map(_.items)NotificationQuery.Empty is unread-only, matching the endpoint's own default.
Marking read returns Future[Unit]:
import scala.concurrent.Future
val cleared: Future[Unit] = client.notifications.markRepositoryRead(owner, name)A notification's subject type is an open enum: a type this library has not
seen decodes to NotificationSubjectType.Other(raw) instead of failing the
page, because Forgejo adds subject types between releases.
Six operations: markdown rendering in two forms, the three settings/*
endpoints, and the instance signing key.
import com.worxbend.codeberg4s.miscellaneous.MarkdownMode
import com.worxbend.codeberg4s.miscellaneous.MarkdownRenderRequest
import com.worxbend.codeberg4s.miscellaneous.SigningKey
import scala.concurrent.Future
val html: Future[String] =
client.misc
.renderMarkdown(MarkdownRenderRequest.of("# codeberg4s").copy(mode = MarkdownMode.Gfm))
.map(_.html)
val pageSizeCeiling: Future[Long] = client.misc.apiSettings().map(_.maxResponseItems)
// None means the instance does not sign commits, which is a legitimate answer
// and not an error — hence Option rather than a 404.
val key: Future[Option[SigningKey]] = client.misc.signingKey()Every operation exists twice. The convenience rail fails the Future with a
CodebergException, which carries the full CodebergError ADT — so nothing is
lost by using it:
import com.worxbend.codeberg4s.CodebergError
import com.worxbend.codeberg4s.CodebergException
import com.worxbend.codeberg4s.repositories.Repository
import scala.concurrent.Future
def fallback: Repository
val repository: Future[Repository] =
client.repos.get(owner, name).recover:
case CodebergException(CodebergError.Api(_, 404, _)) => fallbackAny case you do not handle stays a failed Future, carrying the same value.
The typed rail never fails the Future:
import com.worxbend.codeberg4s.CodebergError
import com.worxbend.codeberg4s.repositories.Repository
import scala.concurrent.Future
val attempted: Future[Either[CodebergError, Repository]] =
client.repos.attempt.get(owner, name)Pick one per call site. .attempt is the convenience rail with its failure
channel materialised, so the two cannot drift.
CodebergError is a closed family of five:
| Case | Means | Reaction |
|---|---|---|
Transport |
nothing reached the server | safe to retry a safe method |
Api |
the server answered non-2xx; carries status and the parsed body |
branch on status |
DecodingFailed |
a 2xx payload did not match the model | retrying will not help; path and snippet are what a bug report needs |
Validation |
a smart constructor rejected an argument | fix the argument |
RetriesExhausted |
the retry engine gave up; last is preserved |
surface last |
There is no RateLimited case. Forgejo reports rate limiting as an
ordinary 429, so it arrives as Api(ctx, 429, body) — and the retry engine
has usually already honoured Retry-After and given up before you see it,
which arrives as RetriesExhausted wrapping that Api.
Every remote case carries a CallContext — operation id, method, redacted URI,
optional request id, elapsed milliseconds — so you can tell which call failed
without correlating logs. error.describe renders it, bounded and secret-free.
A repository can hold tens of thousands of issues, so no operation returns an 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)A Page[A] carries items, the params that produced it, an optional
totalCount from the x-total-count header, and nextPage / prevPage.
The obvious loop is wrong:
// WRONG. Do not do this.
// if (page.items.size < requestedSize) then "this was the last page"Forgejo clamps limit to the instance's own maximum while echoing the value
you asked for. Ask for 100 on an instance capped at 50 and you get 50 items
back, with nothing in the body saying so. items.size < requested is then true
on every page, and a loop written that way stops after the first one and
silently reports a truncated result as complete. That is the worst kind of bug
in a client library: it does not fail, it under-reports.
PageSize refuses anything above 50 for exactly this reason, but the instance
maximum is configurable and client.misc.apiSettings().map(_.maxResponseItems)
is where the real ceiling lives — so the guard is necessary, not sufficient.
The library decides "is there another page" from the response's rel="next"
Link header and never from how many items came back. Use the same signal:
import com.worxbend.codeberg4s.issues.Issue
import com.worxbend.codeberg4s.issues.IssueQuery
import com.worxbend.codeberg4s.paging.PageParams
import scala.concurrent.Future
def walk(params: PageParams, seen: Vector[Issue]): Future[Vector[Issue]] =
client.issues.list(owner, name, IssueQuery.Empty, params).flatMap: page =>
page.nextPage match
case Some(following) if page.items.nonEmpty => walk(params.at(following), seen ++ page.items)
case _ => Future.successful(seen ++ page.items)Page.isLast is nextPage.isEmpty and says the same thing more briefly. The
page.items.nonEmpty guard is not decoration: some instances advertise a next
page forever, and without it the loop runs until the rate limit stops it.
Two more traps worth naming:
totalCountisOption, andNoneis not zero. Several Forgejo endpoints omitx-total-countentirely. TreatNoneas "unknown".pageandlimittravel together. This library always sends both, because list endpoints that receive a lonelimithave been observed to 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:
import com.worxbend.codeberg4s.paging.PageWalk
PageWalk.all(PageParams.First): params =>
client.issues.list(owner, name, IssueQuery.Empty, params)PageWalk.fold and PageWalk.foreach are the bounded-memory forms — reach for
those on a repository with tens of thousands of issues.
import com.worxbend.codeberg4s.BaseUri
import com.worxbend.codeberg4s.CodebergConfig
import com.worxbend.codeberg4s.UserAgent
import com.worxbend.codeberg4s.ValidationError
import com.worxbend.codeberg4s.auth.Auth
import com.worxbend.codeberg4s.paging.PageSize
import com.worxbend.codeberg4s.retry.RetryPolicy
import scala.concurrent.duration.DurationInt
val selfHosted: Either[ValidationError, CodebergConfig] =
for
base <- BaseUri.from("https://my-forgejo.example/api/v1")
agent <- UserAgent.from("my-app/1.0")
size <- PageSize.from(50)
yield CodebergConfig(
baseUri = base,
auth = Auth.Anonymous,
retry = RetryPolicy.Default,
userAgent = agent,
defaultPageSize = size,
connectTimeout = 10.seconds,
readTimeout = 30.seconds,
)Every field is a validated type, so a misconfigured client fails at
construction rather than on its first call. CodebergConfig.toString is safe to
log: the credential types redact themselves.
Auth is Anonymous, Token(ApiToken) or Basic(username, Password).
RetryPolicy.Default is 3 attempts, 250 ms base delay, 8 s ceiling, full
jitter, and it honours Retry-After. RetryPolicy.Off disables retrying
entirely.
Eligibility is decided per operation, not per policy:
- Every
GETis retried — they are safe. POSTandPATCHthat create or edit something (issues.create,pulls.merge,issues.createComment, …) are never retried. Repeating them could file the same issue twice.- The three mark-read calls (
notifications.markAllRead,markThreadRead,markRepositoryRead) are retried despite beingPUTandPATCH: they carry no body and no query, and marking an already-read thread read again is a no-op.
CodebergClient(config) creates and owns a backend, and close() shuts it
down. If your application already has one:
import com.worxbend.codeberg4s.CodebergClient
import com.worxbend.codeberg4s.CodebergConfig
import sttp.client4.Backend
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
def sharedBackend: Backend[Future]
def config: CodebergConfig
given ExecutionContext = ExecutionContext.global
// close() will NOT close sharedBackend — you own it.
val shared: CodebergClient = CodebergClient.usingBackend(config, sharedBackend)sttp models the connect timeout as a property of the backend rather than of a
request, so connectTimeout is ignored on this path — configure it on the
backend. readTimeout is per request and is honoured either way.
ApiToken is a redacting type. Its toString is ***, string interpolation of
it is ***, and reveal is the only way to get the material out — a method
name you will notice in review. No CodebergError can contain a credential:
the URI inside CallContext is redacted before the context is built, and
CodebergException's message is CodebergError.describe, which is assembled
only from that redacted context and from server-supplied text.
There are tests that assert exactly this, because a leaked token in an exception message is the failure mode that matters most in a library like this one.
The library has no logging dependency and writes nothing to stdout. If you want
request visibility, implement the Telemetry port and pass it at construction:
import com.worxbend.codeberg4s.CallContext
import com.worxbend.codeberg4s.CodebergClient
import com.worxbend.codeberg4s.CodebergConfig
import com.worxbend.codeberg4s.CodebergError
import com.worxbend.codeberg4s.auth.Auth
import com.worxbend.codeberg4s.core.Telemetry
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
given ExecutionContext = ExecutionContext.global
final class PrintingTelemetry extends Telemetry[Future]:
override def onRequest(ctx: CallContext): Future[Unit] =
Future.successful(println(s"-> ${ctx.operation} ${ctx.method.wireName} ${ctx.uri}"))
override def onResponse(ctx: CallContext, status: Int): Future[Unit] =
Future.successful(println(s"<- ${ctx.operation} $status in ${ctx.durationMs}ms"))
override def onError(ctx: CallContext, error: CodebergError): Future[Unit] =
Future.successful(println(s"!! ${ctx.operation}: ${error.describe}"))
val observed: CodebergClient =
CodebergClient(CodebergConfig(Auth.Anonymous), PrintingTelemetry())Three guarantees worth knowing:
- A telemetry failure never fails the call it was observing. Instrumentation that breaks must not break the application it instruments.
- Everything a callback receives is already redacted, so an implementation cannot leak a credential by logging what it is handed.
onRequestandonResponsefire once per attempt. A retried call produces several of each, which is how you see a retry storm.onErrorfires once per failed attempt and once more for the failure the caller finally receives.
Telemetry.noOp is the default and allocates nothing per call, so an
unconfigured client is completely silent.
./mill modules.__.compile # warnings are errors
./mill modules.__.test # unit tests (1072 today)
./mill modules.__.reformat # scalafmt
./mill modules.__.fix # scalafix
./verify.sh # the pre-handoff gate
./verify.sh --with-slow # plus duplication and CRAP analysis
./verify.sh --nightly # plus mutation testingEvery Scala block in this file compiles against the current sources under the
project's own flags (-deprecation -feature -Wunused:all -Wvalue-discard -Wnonunit-statement -Werror). That check is manual today; wiring mdoc so the
build enforces it is a Phase 4 line in
docs/ROADMAP.md.
verify.sh runs format check, lint, a zero-warning compile, the unit suite, an
architecture-boundary check and coverage, in that order. Its slow and nightly
steps skip with a printed notice when their runner script is absent, so read
the step output rather than trusting the exit code — see
docs/CONSTITUTION_MAPPING.md for what is
actually proven today.
modules/it is the environmentally-unsuitable boundary: it needs Docker or the
live network, so verify.sh never runs it. Both suites tag every test
Integration.
# Container suite — starts codeberg.org/forgejo/forgejo:12, bootstraps an admin,
# a repository and a token, then exercises the client against it. Needs Docker.
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.ForgejoContainerSuite
# Override the image, e.g. to match what CI has cached:
FORGEJO_IT_IMAGE=codeberg.org/forgejo/forgejo:12 \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.ForgejoContainerSuite
# Live read-only smoke against https://codeberg.org. Opt-in; without the
# variable every test is *skipped*, not failed, so a disabled run is visibly
# different from a run with nothing to do.
CODEBERG_IT=1 \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.CodebergLiveSmokeSuite
# A token only widens the rate limit; the suite asserts nothing that needs one.
CODEBERG_IT=1 CODEBERG_IT_TOKEN=... \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.CodebergLiveSmokeSuite
# Both, plus everything else in the module:
CODEBERG_IT=1 ./mill modules.it.testThe live suite is read-only by construction — six GETs against a repository
it does not own — and must stay that way.
Recorded in docs/adr/. Start with
ADR-0005, which explains why the API is
Future-based, and ADR-0001,
which explains why the models are hand-written rather than generated from the
Swagger spec.
The measured divergences between the pinned Swagger spec and what Codeberg
actually returns are in docs/HAZARDS.md. Two of the
project's original assumptions turned out to be wrong there, which is why the
models are built from captured fixtures rather than from the spec.
CHANGELOG.md, Keep-a-Changelog format, semantic versioning.
MIT. See LICENSE.