Swift
Calls
One call for every route the API has, for an app that would rather draw its own board. Same key, same voter, same offline queue as the built in screens.
The shape of it
There is one call per route and nothing else. Each one names what it acts on the way the request does, and hands back what that request answered.
| Call | Request, under /api/v3/projects/{project} | Answers |
|---|---|---|
me() | POST /identity | Identity |
board(refresh:) | GET / | Board |
features(_:) | GET /features | Page<Feature> |
feature(id:) | GET /features/{id} | Feature |
comments(featureID:page:perPage:) | GET /features/{id}/comments | Page<Comment> |
changelog(page:perPage:) | GET /changelog | Page<Release> |
release(id:) | GET /changelog/{id} | Release |
messages(page:perPage:) | GET /messages | Page<Message> |
dismiss(messageID:) | POST /messages/{id}/dismiss | MessageDismissed |
upvote(featureID:) | POST /features/{id}/vote | VoteResult |
unvote(featureID:) | DELETE /features/{id}/vote | VoteResult |
suggest(_:details:) | POST /features | Feature |
comment(_:featureID:replyingTo:) | POST /features/{id}/comments | Comment |
like(commentID:) | POST /comments/{id}/like | LikeResult |
delete(commentID:) | DELETE /comments/{id} | CommentDeleted |
Two more that are not routes: waiting, how many writes are queued, and send(), which puts them out.
The project is never an argument, because the key names it.
The voter is never an argument either, and cannot be. An argument naming the voter would let anyone vote as anyone. One is minted on first use, kept on the device, and sent in a header on every request after it. me() reports who that is.
Where they run
Every call is @MainActor, and every one that reaches the network is async throws. waiting and send() are async and cannot throw, because a queue that is already on this device has nothing left to fail at.
Every write is @discardableResult, so a call whose answer you do not need is one line with no warning on it. Every model is Sendable, so a Feature crosses actors without one either.
Task { @MainActor in
let page = try await VoteFirst.features()
self.features = page.items
}
Cancelling the task cancels the request under it. A cancelled call throws CancellationError rather than a VoteFirstError, so a view that disappears mid load does not draw an error about it.
What one press sends
Voting for feature 21519 is one line, and this is all of what goes on the wire.
let vote = try await VoteFirst.upvote(featureID: 21519)
The first write of the app's life mints the voter.
POST /api/v3/projects/demo-product/identity X-API-Key: vf_pk_31e91b2c...
{
"data": {
"voter_id": "anon_41bdabf9...",
"kind": "anonymous",
"anon_token": "c08571fdafe994...",
"display_name": "VelvetOwl_3069",
"banned": false,
"voted_feature_ids": []
}
}
The token from that answer is kept in the keychain, and every request after it carries it.
POST /api/v3/projects/demo-product/features/21519/vote X-API-Key: vf_pk_31e91b2c... which board X-VF-Anon: c08571fdafe994... who is voting Idempotency-Key: 4F2A9C7B-1D3E... so a retry cannot vote twice
{
"data": {
"feature_id": 21519,
"has_voted": true,
"vote_count": 61,
"score": 61
}
}
Every response has the same three keys, data, meta and error, and the package unwraps them for you. meta carries the paging numbers on a list and is null everywhere else.
Reading the board
let board = try await VoteFirst.board()
One document describing the project rather than the voter: its name and slug, its boards and their columns, its tags, its display settings, its theme, and what it allows. It is what you need before drawing anything, because it says which columns exist, what they are called, and whether a vote on one would be accepted.
It is held after the first call, because a custom interface asks for it on every screen and it changes only when the owner changes it.
let fresh = try await VoteFirst.board(refresh: true)
Every field is in the reference.
Reading features
let page = try await VoteFirst.features()
The default is the roadmap, most wanted first, twenty at a time.
public struct FeatureQuery: Sendable, Equatable {
public init(sort: Sort = .top,
scope: Scope = .roadmap,
column: Int? = nil,
tagIDs: [Int] = [],
statuses: [FeatureStatus] = [],
search: String? = nil,
page: Int = 1,
perPage: Int = 20)
}
| Sort | Order |
|---|---|
.top | Most voted first. The default |
.votes | The plain headcount, whatever the project weights by |
.trending | What has been voted for lately |
.new | Newest first |
.oldest | Oldest first |
.comments | Most discussed first |
.board | The order the owner arranged, which is what the roadmap draws |
| Scope | What is in view |
|---|---|
.roadmap | The roadmap, which is the board every project has. The default |
.board("q3") | One custom board, by slug |
.everyBoard | Every public feature, wherever it is filed. The question a most wanted list asks |
.everyBoard cannot be narrowed by column, and the package does not send a column with it. The server refuses the two together, and a shape that cannot express the refusal is better than one that sends it and reads the complaint back.
Asking a narrower question
let query = FeatureQuery(
sort: .trending,
scope: .board("q3"),
column: 4,
tagIDs: [7, 9],
statuses: [.open, .planned],
search: "dark mode",
perPage: 50
)
let page = try await VoteFirst.features(query)
| Field | What it does |
|---|---|
column | One column of whichever board is in view. -1 is the roadmap's archive |
tagIDs | Features carrying any of these tags. The ids are in board.tags |
statuses | .open, .planned, .inProgress, .done, .denied, .archived |
search | Words in the heading or the description. An empty string is not sent |
page | From 1. Anything lower is sent as 1 |
perPage | From 1 to 100. Anything higher is sent as 100 |
A status the board hides answers with nothing rather than with an error. Denied and archived features are only there when the project publishes them.
Paging
Every list answers a Page, which is the items and the numbers around them.
let page = try await VoteFirst.features() page.items // [Feature] page.meta.total // how many there are in all page.hasMore // whether there is another page page.nextPage // the number to ask for, or nil
let query = FeatureQuery(sort: .top, statuses: [.open])
var page = try await VoteFirst.features(query)
var everything = page.items
while let next = page.nextPage {
page = try await VoteFirst.features(query.page(next))
everything += page.items
}
query.page(_:) returns a copy with the page changed and the rest of the query intact, so there is no way to turn a page and lose a filter.
Reading one feature
let feature = try await VoteFirst.feature(id: 21519)
The same object a list carries. Ask for it when you have an id from somewhere else, such as a deep link, or when you want the current counts for one thing without fetching a page.
This is the one read that changes something: it counts a view, at most once an hour for the same voter and the same feature. Counting a view does not move the feature's updatedAt.
A feature that is hidden, unapproved, or on another project answers notFound, so guessing ids reaches nothing a list would not have shown.
Reading comments
let thread = try await VoteFirst.comments(featureID: 21519) let more = try await VoteFirst.comments(featureID: 21519, page: 2, perPage: 50)
Newest first. Replies are not separate items: each comment carries its own replies, in the order they were written, and replies are one level deep. meta.total counts the top level comments, not the replies under them.
comment.isOwn is what tells you which ones this voter may delete.
Reading the changelog
let shipped = try await VoteFirst.changelog() let release = try await VoteFirst.release(id: 4)
Published releases, most recent first, featured ones before the rest. Each carries the features it shipped, its body as the markdown the owner wrote, and the same body as HTML for an app that would rather show a web view than lay markdown out.
release(id:) counts a view the same way feature(id:) does.
Reading your own messages
let mine = try await VoteFirst.messages() try await VoteFirst.dismiss(messageID: 9)
What you wrote in the dashboard after this build shipped, highest priority first. Only the messages this reader should see come back: the platform and the build are read from the bundle and sent for you, and what this voter pays comes from their identity, which is minted first.
A message carries a title, a plain text body, a tone, whether it means to interrupt, whether it may be put away, and at most one action. message.action answers the label and the URL together, and only when both arrived and the address is one worth opening.
dismiss(messageID:) is recorded for the voter rather than for the device, so it holds after a reinstall and on their other devices. It is refused for a message you marked as one people must keep seeing. Made with no connection, it waits in the same queue every other write does.
Most apps need neither call: .voteFirstMessages() draws them. These are for an app that would rather draw its own.
Voting
let vote = try await VoteFirst.upvote(featureID: 21519) let back = try await VoteFirst.unvote(featureID: 21519)
vote.featureID // 21519, the feature this answer is about vote.hasVoted // true vote.voteCount // 61, or nil vote.score // 61, or the weighted score on a project that weights votes
Two calls rather than a toggle, so which one ran is never a guess.
Voting twice is not an error, and neither is taking back a vote that is not there. Either one comes back as the state you asked for, with voteCount nil, which means the count you already have still stands. The server answers 409 for both and the package settles it, because nothing is wrong and nothing changed.
voteCount is also nil on a project that hides vote counts. Both cases mean the same thing to a caller: draw the count you already had.
A feature that does not exist is a 404 and does throw. Same code, different status, and the package tells them apart so a real mistake does not look like a settled one.
A closed column throws too. Ask the board first, below.
Suggesting
let filed = try await VoteFirst.suggest("Dark mode", details: "The white burns at night.")
| Heading | Required. 254 characters at most |
| Details | Required. 2000 characters at most |
Both are required by the server. details has a default of "" so the call reads as a sentence, and sending it empty answers validationFailed on the description field, so check before you send rather than after.
A suggestion is held for the owner to approve before anyone sees it, including the person who wrote it. filed.isApproved is false until they do, and until then it is not on the board and takes no comments. Reading it back by its own id answers notFound, even for the voter who just filed it, so keep the value you were handed rather than fetching it again.
Say so in your interface. Somebody who files a suggestion, cannot find it, and gets a not found when they look assumes it was lost. The built in form ends in a confirmation for exactly this reason.
Commenting
let said = try await VoteFirst.comment("Yes please", featureID: 21519)
let reply = try await VoteFirst.comment("Agreed", featureID: 21519, replyingTo: said.id)
2000 characters at most. Replies are one level deep: replying to a reply answers validationFailed on parent_id, and the message says to reply to the comment that one replies to instead.
A voter the project has stopped writing gets voterBanned here. They can still read and vote.
Liking
let like = try await VoteFirst.like(commentID: said.id) like.hasLiked // which of the two just happened like.likeCount // where the count stands now
One route does both. It likes a comment this voter has not liked, and takes the like back if they have.
Deleting a comment
let gone = try await VoteFirst.delete(commentID: said.id) gone.deleted // true gone.commentCount // where the feature's comment count stands now
Only a comment this voter wrote, which Comment.isOwn reports. Somebody else's answers notAllowed.
Deleting a comment that has replies deletes the replies with it, which is why the count can drop by more than one.
Who is voting
let voter = try await VoteFirst.me() voter.voterID // what every vote and comment of theirs is recorded against voter.displayName // what their comments are signed with voter.votedFeatureIDs // what they have already voted for voter.banned // the project has stopped them writing; they still read voter.kind // .anonymous or .sso
Nothing has to be called first. The voter is minted on the first write that needs one and the same one is used from then on. me() is how you learn who that is before drawing a board, so the vote buttons are in the right state on the first render rather than after the first press.
Voters is the whole of it, including signing your own users in.
Writes waiting to send
let waiting = await VoteFirst.waiting // how many writes are queued let sent = await VoteFirst.send() // put them out, and how many left the queue
A write made with no connection throws and is kept. The built in screens send the queue when one appears; an app drawing its own board calls send() when the connection comes back. Offline and retries has the rest.
Ask before you draw the button
An owner can close one column to votes while the rest of the board stays open, and can turn comments or suggestions off entirely. A button drawn without asking is refused when it is pressed, so ask the board first.
if board.allowsVoting(on: feature) { /* draw the vote button */ }
if board.allowsComments { /* draw the comment field */ }
if board.allowsSuggestions { /* draw the suggest button */ }
allowsVoting(on:) answers for one feature, because that is the question. It reads the project's setting, this board's setting and this column's own, which are three different places a vote can be turned off.
Folding a result back in
If you hold Feature and Comment values in your own state, applying puts a result into one.
feature = feature.applying(try await VoteFirst.upvote(featureID: feature.id)) comment = comment.applying(try await VoteFirst.like(commentID: comment.id))
It is the one convenience here, and it is deliberate: without it every caller keeps a second table of vote state beside the features and has to remember to keep the two in step. A result for another id is ignored rather than applied to the wrong row, and a result with no count leaves the count alone.
Read the result yourself if you keep your own model. There is nothing in applying that you cannot do with three assignments.
A whole board
struct MyBoard: View {
@State private var features: [Feature] = []
var body: some View {
List($features) { $feature in
HStack {
Text(feature.heading)
Spacer()
Button {
Task {
let vote = feature.hasVoted
? try await VoteFirst.unvote(featureID: feature.id)
: try await VoteFirst.upvote(featureID: feature.id)
feature = feature.applying(vote)
}
} label: {
Label("\(feature.voteCount ?? 0)",
systemImage: feature.hasVoted ? "heart.fill" : "heart")
}
}
}
.task { features = (try? await VoteFirst.features().items) ?? [] }
}
}
That is a working board. A real one asks board() first so the vote button is drawn in the right state, catches the error rather than swallowing it, and pages. All three are above.
A vote made this way reaches a built in screen that is already on a tab, so mixing the two is fine.
What each call can throw
Every call throws VoteFirstError. These are the ones worth handling per call rather than in one place; the whole list is in Errors.
| Call | What it answers when it is refused |
|---|---|
upvote, unvote | notAllowed on a closed column, notFound on a feature that is gone |
suggest | validationFailed on heading or description, notAllowed when the project takes no suggestions, voterBanned |
comment | validationFailed on content or parent_id, notAllowed when comments are off, voterBanned |
delete | notAllowed on somebody else's comment |
| any of them | network with no connection, rateLimited over budget, invalidKey on a rotated key |