← All posts

I Replaced My Hand-Rolled Google Calendar API Client With Google's Swift Package

swiftgoogle-calendargoogle-apimacosdevlogapiswift-package-manager

Hand-rolled Google Calendar API client replaced with Google's Swift Package

I spent roughly a month building my own Google Calendar API client before I realized Google already ships a Swift Package for this.

That sentence is painful to write, because it sounds like the kind of thing you are supposed to know before touching the code. But I am still learning the Swift ecosystem in public, and this is one of those places where the obvious thing was not obvious to me yet.

Would I hand-roll this integration again? No.

Was it a waste of time? Also no.

I learned more about Google Calendar's API surface in the last month and a half than I would have learned in years if I had started with the happy-path library wrapper.

The version I built first

The first version of hora's Google integration was a local Swift package called HoraGoogleAPI. It had no external Google client dependency. It was just Foundation, URLSession, access tokens, JSON decoding, and a lot of endpoint-specific code.

The package manifest was almost comically small:

// Packages/HoraGoogleAPI/Package.swift, before the migration
dependencies: [
    .package(path: "../HoraCore"),
],
targets: [
    .target(
        name: "HoraGoogleAPI",
        dependencies: ["HoraCore"]
    ),
]

That meant every API shape was my responsibility. Fetching events looked like normal app code until you remember how many details hide inside "normal":

let encodedID = calendarID.addingPercentEncoding(
    withAllowedCharacters: .urlPathAllowed
) ?? calendarID
 
var components = URLComponents(
    string: "\(baseURL)/calendars/\(encodedID)/events"
)!
components.queryItems = [
    URLQueryItem(name: "timeMin", value: formatter.string(from: min)),
    URLQueryItem(name: "timeMax", value: formatter.string(from: max)),
    URLQueryItem(name: "singleEvents", value: "true"),
    URLQueryItem(name: "orderBy", value: "startTime"),
    URLQueryItem(name: "maxResults", value: "2500"),
]
 
let data = try await authenticatedRequestWithRetry(url: components.url!)
let response = try JSONDecoder().decode(GoogleEventsResponse.self, from: data)

There is nothing wrong with this code in isolation. The problem is that a calendar app is not one endpoint. It is a swarm of tiny contracts: list calendars, fetch colors, fetch events, sync tokens, create events, patch events, delete events, move events, expand recurring instances, RSVP, watch channels, stop channels, refresh auth, recover scopes, preserve fields from other clients, and do all of that without freezing SwiftUI or burning through quota.

I underestimated that surface area.

Linear became a map of everything I did not know

The best way to see the cost of a hand-written API client is not the code. It is the bug tracker.

Before TestFlight, I opened a Google Calendar API audit issue because Proxyman showed something terrifying: editing, creating, and deleting events in the UI did not show the expected PUT, POST, PATCH, or DELETE calls to /calendar/v3/calendars/{id}/events. That became SZA-86.

Then the real edge cases started showing up:

IssueWhat it taught me
SZA-88events.update with PUT is dangerous because fields your app does not serialize can be wiped. PATCH was the right default.
SZA-89Optimistic events can have an empty Google event ID for a short window. If the user edits or deletes during that window, you can build malformed URLs.
SZA-91Moving an event across calendars should use events.move, not delete-and-create, or you lose etag, iCalUID, Meet data, attachments, and attendee history.
SZA-92Google exposes color palettes through /colors; hardcoding the palette works until it does not.
SZA-93Calendar color, selected state, hidden state, and summary override live on calendarList, not the global calendars resource.
SZA-94My event body did not model enough of Google's event resource: extendedProperties, guestsCan*, source, email reminders, optional attendees.
SZA-95Recurring events are their own universe. events.instances exists for a reason.
SZA-187 / SZA-256Retry is not enough for quota. You need rate limiting, cooldowns, and metrics.

Linear issue map for the Google Calendar API edge cases

This is the part I am glad I did manually. Not because the manual client was the best long-term architecture, but because every issue forced me to understand the actual API contract.

I now know why PATCH still replaces whole arrays. I know why calendarList.patch is not the same thing as calendars.patch. I know why delete-and-create is not a move. I know why "responding to an invite" means preserving everyone else in the attendee array, not sending only yourself. I know why a sync token expiring is not an error state, it is a protocol state.

That knowledge is expensive. I paid for it with time.

The rate limit bug that made the abstraction leak

The most humbling bug was rate limiting.

I already had exponential backoff, but Sentry still showed quota failures from beta builds. The error was Google's per-user query limit: Queries per minute per user. A single account could create enough pressure during sync, push renewal, and invitation refresh that retry only delayed the storm.

So I added a token bucket inside HoraGoogleAPI:

// SZA-256: client-side throttle scoped per account so we never spend
// Google's per-user 600 req/min quota faster than retry/backoff can
// recover from.
private let bucket = TokenBucket()

The fix had three layers:

  • A per-account token bucket at 8 requests/second with burst 10.
  • A sync cooldown after repeated 429 or 403 rate-limit failures.
  • Metrics for request duration, API errors, cooldown trips, and token-bucket waits.

That is the kind of plumbing you do not appreciate when you only call service.executeQuery(...) and move on. But it is also plumbing I do not want to keep reinventing forever.

Google Calendar API rate limit cooldown state in hora

Then I found the Google package

The package I should have known about earlier is Google's REST client for Apple platforms:

.package(
    url: "https://github.com/google/google-api-objectivec-client-for-rest.git",
    from: "5.2.0"
),
.package(
    url: "https://github.com/google/GTMAppAuth.git",
    from: "5.0.0"
)

The name is not glamorous. It is Objective-C under the hood. In Swift, the types have names like GTLRCalendarQuery_EventsList, GTLRCalendar_Event, and GTLRCalendarService.

But it gives me something my hand-written client never could: generated query types for Google's API surface.

After the migration, the package manifest changed from "just HoraCore" to:

dependencies: [
    .package(path: "../HoraCore"),
    .package(
        url: "https://github.com/google/google-api-objectivec-client-for-rest.git",
        from: "5.2.0"
    ),
    .package(
        url: "https://github.com/google/GTMAppAuth.git",
        from: "5.0.0"
    ),
]

And the hand-built URL for listing events became a typed query:

let q = GTLRCalendarQuery_EventsList.query(withCalendarId: calendarID)
q.timeMin = GTLRDateTime(date: min)
q.timeMax = GTLRDateTime(date: max)
q.singleEvents = true
q.orderBy = kGTLRCalendarOrderByStartTime
q.maxResults = 2500

For writes, the code now creates a GTLRCalendar_Event and uses the API's own query object:

let eventObj: GTLRCalendar_Event = makeGTLRObject(
    GTLRCalendar_Event.self,
    json: jsonObj
)
let query = GTLRCalendarQuery_EventsPatch.query(
    withObject: eventObj,
    calendarId: event.calendarID,
    eventId: event.googleEventID
)
query.sendUpdates = sendUpdates

This is still not "free." I still bridge async/await. I still map Google's Objective-C error shapes into AppError. I still disable GTLR's internal retry because I want retries, token buckets, and metrics in one place. I still prewarm a few query types to avoid runtime first-touch races in tests.

But the center of gravity moved. I no longer have to manually assemble every endpoint and hope I encoded the shape correctly.

Before and after: URLComponents code replaced with GTLR query objects

What changed in the migration

The actual migration commit touched the API package, push channels, authentication, and sync manager. The important part was not a giant rewrite. It was a swap of responsibility.

Before:

  • HoraGoogleAPI owned URL construction.
  • HoraGoogleAPI owned query parameters.
  • HoraGoogleAPI owned JSON request bodies.
  • HoraGoogleAPI owned pagination loops.
  • HoraGoogleAPI owned token refresh, retry, error mapping, and rate limiting.

After:

  • GTLR owns the generated Google API query surface.
  • hora still owns product semantics: what counts as a sync, what gets cached, when to retry, when to cool down, what to preserve, and how to reconcile into SwiftData.

That split feels much healthier.

The migration also hardened a few areas that had become fragile:

  • Push sync dedupe around Google watch callbacks.
  • Void GTLR responses for operations like delete/stop channel.
  • Keychain auth recovery for stale GTMAppAuth sessions.
  • Scope errors becoming a clear "sign out and reconnect" path instead of a vague 403.

This was not a "delete old code, everything works" day. It was a "move onto the right primitive, then spend another pass making it production-shaped" day.

Google Calendar push sync flow through hora

The part I would do differently

If I were starting from zero today, I would not begin by writing a full REST client by hand.

I would start with the official Google package, then immediately build a thin domain layer around it:

  • GoogleCalendarService for Calendar API operations.
  • GooglePeopleService for contact search.
  • A single async executor that handles retry, token refresh, logging, metrics, and error mapping.
  • Typed repository code above it that speaks in hora concepts, not Google transport concepts.

That is basically where the app is now.

The mistake was not "writing code." The mistake was assuming that because REST looks simple, the integration would stay simple. Calendar APIs punish that assumption. They are full of old data, shared ownership, recurring exceptions, other clients' metadata, per-user calendar overrides, OAuth scope drift, quota limits, and synchronization contracts that only show up when real users connect real accounts.

Why I am still glad I did it the hard way

This is the annoying nuance: I would not do it again, but I am glad I did it once.

If I had started with GTLR on day one, I probably would have shipped faster. I also would have understood less.

I would have seen GTLRCalendarQuery_EventsMove and thought "nice, there is a move method." Instead, I first implemented delete-and-create, watched the edge cases, read the API docs, opened SZA-91, and understood why move preserves identity.

I would have used EventsPatch without feeling the damage of PUT. Instead, SZA-88 forced me to think about every field hora does not own.

I would have treated quota as an operational concern. Instead, SZA-256 made rate limits part of the app architecture.

That is a different kind of learning. It is slower, but it sticks.

The Google Calendar API learning loop: issue, docs, code, TestFlight feedback

The takeaway

The lesson is not "never build your own client." Sometimes you need to. Sometimes the official library is missing a feature, does not fit your runtime, or hides too much.

The lesson is: when you are new to an ecosystem, spend an hour looking for the boring official package before you spend a month proving why it exists.

For hora, the result is better than either extreme. The app now sits on Google's generated API client, but the month of hand-written work left behind a much sharper domain layer. I know what abstractions I am accepting, and I know where they leak.

That is probably the best outcome I could ask for from a mistake.

Join the beta community

Daily builds. Direct line to me.
Other early testers.

Discord is where feedback turns into fixes. That's where I'll be every day for the next few weeks.

Join the Discord  →

discord.gg/8JFz4FfBGQ


The hora Calendar public beta is on TestFlight. If you are building anything serious on top of Google Calendar, I hope this saves you at least one wrong turn.

Follow the build at @moto_szama, check out hora Calendar, or reach out at hello@horacal.app.

Stay in the loop

Get launch updates.

Be first to know when hora launches. No spam.

Skip the refresh cycle.

Drop your email, get the invite the moment hora ships.

Support this project on GitHubStar on GitHub
Waitlist member avatarWaitlist member avatarWaitlist member avatarWaitlist member avatarWaitlist member avatar

240+ Mac folks already on the Beta TestFlight waitlist