api · mockup
// /graphql

Civic data is a tree.
Query it like one.

A ballot has races, which have candidates, which have finance records, donors, endorsements, and stances. REST would force five round-trips. GraphQL gets it in one.

// nested query

A real production query.

This is what a typical voter-guide page actually needs in one render pass.

POST /graphql · query
query VoterGuide($addr: String!) {
  ballot(address: $addr) {
    election { id, date }
    races {
      office
      candidates {
        id
        name
        party
        endorsements { org, url }
        stances { topic, summary }
        finance {
          totalRaised
          topDonors(limit: 3) { name, amount }
        }
      }
    }
    measures {
      id
      title
      yesMeans
      noMeans
      fiscalImpact
    }
  }
}
REST equivalent · 6 round-trips
GET /v1/ballot?address=...
→ GET /v1/elections/{id}
→ GET /v1/ballots/{id}
  → for each race:
    → GET /v1/candidates/{id}
      → GET /v1/finance/{candidate_id}
      → GET /v1/candidates/{id}/endorsements
      → GET /v1/candidates/{id}/stances
  → for each measure:
    → GET /v1/measures/{id}

// ~40 HTTP requests for a typical municipal ballot.
// GraphQL: 1 request, ~12 KB response.
// schema map

Top-level types and edges.

type Query {
  ballot(address: String, ocdId: ID): Ballot
  jurisdiction(ocdId: ID!): Jurisdiction
  candidate(id: ID!): Candidate
  measure(id: ID!): Measure
  election(id: ID!): Election
}

type Ballot       { election, races: [Race!]!, measures: [Measure!]!, pollingPlaces: [PollingPlace!]! }
type Race         { office, district, candidates: [Candidate!]!, winner: Candidate }
type Candidate    { name, party, bio, finance: Finance, endorsements: [Endorsement!]!, stances: [Stance!]! }
type Measure      { title, summary, yesMeans, noMeans, fiscalImpact, supporters, opponents, fullTextUrl }
type Finance      { totalRaised, totalSpent, cashOnHand, donors(limit: Int): [Donor!]!, filings: [Filing!]! }
type Officeholder { name, office, jurisdiction, termEnds, contact: Contact }
// production story

Persisted queries, edge cache, introspection.

Persisted queries
Clients register query SHA → server stores. Wire becomes POST {sha, vars}. CDN-cacheable. Defeats query-injection.
Edge cache
Public ballots cached at the CDN by (sha, vars). Authenticated queries bypass. Cache-Control varies by scope.
Introspection
Full schema introspection on. Powers GraphiQL playground, codegen for typescript / swift / kotlin clients.