Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d7afe65
fix(client): honour CodebergConfig.defaultPageSize as the first-page …
w0rxbend Aug 31, 2026
e78938e
test(client): enforce Api/Attempt rail parity by reflection
w0rxbend Aug 31, 2026
bfaac46
fix(client): scope repository Actions operation ids under repos.actions
w0rxbend Aug 31, 2026
552f0ec
style(client): reformat AttemptParitySuite scaladoc wrapping
w0rxbend Aug 31, 2026
50f5592
refactor(api)!: name repository sub-resource listings as bare nouns
w0rxbend Aug 31, 2026
8e2d448
refactor(api)!: rename every PageParams parameter to params
w0rxbend Aug 31, 2026
bc32fdf
refactor(model)!: move Owner and RepoName to the package root
w0rxbend Aug 31, 2026
31f46e2
feat(model): re-export the everyday surface from the package root
w0rxbend Aug 31, 2026
2b078fa
feat(model): add compile-time literal constructors for path identifiers
w0rxbend Aug 31, 2026
194f708
refactor(core): add shared request-shape builders to CodebergRequest
w0rxbend Aug 31, 2026
7ca436e
refactor(client): build issue requests from the shared shape builders
w0rxbend Aug 31, 2026
73edd11
refactor(client): build organization requests from the shared builders
w0rxbend Aug 31, 2026
ce6431c
refactor(client): build account requests from the shared builders
w0rxbend Aug 31, 2026
b517b30
refactor(client): delete the duplicate request builders that matched
w0rxbend Aug 31, 2026
d301c3b
refactor(client): reshape the last five private request builders
w0rxbend Aug 31, 2026
4c87c5f
refactor(client): read IssueApi decoders and paths from the group
w0rxbend Aug 31, 2026
8925d7a
refactor(codec): render every paging window from one place
w0rxbend Aug 31, 2026
d57db8d
style(domain): reformat the segment-literal sources
w0rxbend Aug 31, 2026
8b9620b
refactor(client): decode whole-array responses through WireDecode.vector
w0rxbend Aug 31, 2026
223a4ad
refactor(client): rename WireDecode.of to WireDecode.single
w0rxbend Aug 31, 2026
6942154
refactor(codec): fold the per-group array helpers into ArrayElements
w0rxbend Aug 31, 2026
774bcd9
refactor(codec): promote the optional-field conversion into Wire
w0rxbend Aug 31, 2026
aff3f7c
refactor(codec): move the instant renderer into Timestamps
w0rxbend Aug 31, 2026
55d8933
refactor(codec): promote the request-body value builders into codec
w0rxbend Aug 31, 2026
0a2f0e5
refactor(codec): fold nested DTO conversion into Wire.nested
w0rxbend Aug 31, 2026
e7ee18c
refactor(model): promote one shared positive-id validator
w0rxbend Aug 31, 2026
0037268
refactor(core): serve binary calls from the shared pipeline path
w0rxbend Aug 31, 2026
4f3e289
test(client): share one stub-backend harness across the API suites
w0rxbend Aug 31, 2026
35683f5
fix(build): exclude the Property tag from every unit-gate module
w0rxbend Aug 31, 2026
29a7e94
test: tag every property automatically in PropertyBase
w0rxbend Aug 31, 2026
a4da84b
docs(site): import Owner and RepoName from the package root
w0rxbend Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 24 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.repositories.Owner
import com.worxbend.codeberg4s.repositories.RepoName
import com.worxbend.codeberg4s.*

import scala.concurrent.ExecutionContext
import scala.concurrent.Future
Expand All @@ -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:
Expand Down Expand Up @@ -129,8 +131,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

Expand Down Expand Up @@ -166,7 +168,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))
```

Expand Down Expand Up @@ -459,16 +461,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

Expand Down Expand Up @@ -520,12 +524,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)
```

Expand Down Expand Up @@ -573,6 +579,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.
Expand Down
15 changes: 14 additions & 1 deletion modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -226,4 +239,4 @@ object CodebergClient:
ApiErrorBodyCodec.parse,
)

new CodebergClient(pipeline, port, timer, owned)
new CodebergClient(config, pipeline, port, timer, owned)
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ object VersionApi:
)

private val Decoder: Decode[ServerVersion] =
WireDecode.of(Json.decoder[ServerVersionDto])(_.toDomain)
WireDecode.single(Json.decoder[ServerVersionDto])(_.toDomain)
29 changes: 27 additions & 2 deletions modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,12 +18,36 @@ 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 [[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 [[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
* 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]] =
single(wire)(dtos => toDomainAll(JsonPath.Root, dtos))
Loading