Trust is earned, not given

A different perspective

2021-04-20 · Projects

Shopify in C#, part 3: the GraphQL Admin API — one endpoint, variables, cost budget, and real customers

Part 3from the Shopify in C# series · 6 parts in all

The GraphQL Admin API has been quietly eating the REST surface, and this year two changes make it the default: API versions are now real (the URL pins one), and private apps are being retired in favor of custom apps with proper Admin API access tokens (shpat_...). Part 3 is the pivot: one endpoint, typed queries, and customers in GraphQL with the pagination and cost model explained.

The new auth and the one endpoint

// 2021: custom app = static Admin API access token (shpat_...), same token
// every call, no OAuth dance for your own store.
var http = new HttpClient();
http.BaseAddress = new System.Uri("https://maplecart.myshopify.com/");
http.DefaultRequestHeaders.Add("X-Shopify-Access-Token", accessToken);

// EVERYTHING is a POST to one versioned endpoint. The version in the URL is
// a contract: objects and fields are stable inside it, upgrades are deliberate.
const string Endpoint = "admin/api/2021-01/graphql.json";

static async Task<JsonElement> PostGraphAsync(HttpClient http, string query,
    object? variables = null)
{
    var body = JsonSerializer.Serialize(new { query, variables });
    using var content = new StringContent(body, Encoding.UTF8, "application/json");
    var resp = await http.PostAsync(Endpoint, content);
    var json = await resp.Content.ReadAsStringAsync();
    using var doc = JsonDocument.Parse(json);
    // GraphQL reports errors BESIDE data, not as an HTTP status: 200 with
    // { "errors": [...] } is a failed query. Check errors first, always.
    if (doc.RootElement.TryGetProperty("errors", out var errs))
        throw new InvalidOperationException(errs.ToString());
    return doc.RootElement.GetProperty("data").Clone();
}

Customers: a nested query in one round-trip

// The same data part 2 fetched in 3-4 REST calls, as one GraphQL document:
const string customerQuery = @"
query customer($id: ID!) {
  customer(id: $id) {
    id
    firstName
    lastName
    email
    tags
    ordersCount        # the rollup REST called orders_count
    amountSpent { amount currencyCode }    # money is structured here
    addresses { address1 city provinceCode countryCodeV2 zip }
    metafields(first: 10, namespace: ""loyalty"") {
      edges { node { key value } }
    }
    orders(first: 5) { edges { node { name totalPriceSet { shopMoney { amount } } } } }
  }
}";
var data = await PostGraphAsync(http, customerQuery, new { id = customerId });
var c = data.GetProperty("customer");
Console.WriteLine(c.GetProperty("email").GetString());
// Note the property naming: REST snake_case -> GraphQL camelCase, and
// ""loyalty"" -> "loyalty" (C# verbatim strings double embedded quotes).

Pagination in GraphQL: edges, nodes, pageInfo

// The connection pattern. first/after replaces limit/page_info.
const string listQuery = @"
query customers($cursor: String) {
  customers(first: 50, after: $cursor, query: ""tag:vip"") {
    edges {
      node { id email firstName lastName }
      cursor              # per-edge cursor for resuming
    }
    pageInfo { hasNextPage }
  }
}";
// Walk: pass the LAST edge's cursor as $cursor until hasNextPage is false.
// (Edge = node + cursor + per-edge extras like rollback cost; node = payload.)

The cost budget: why your big query got throttled

GraphQL charges per query by shape, not per request: each nesting level multiplies cost against a leaky-bucket budget (REST was 2 req/s flat). The response extensions.cost tells you what you spent and what the bucket holds — read it once in development and size your pages accordingly:

// extensions: { cost: { requestedQueryCost: 260, actualQueryCost: 262,
//   throttleStatus: { maximumAvailable: 1000.0, currentlyAvailable: 738,
//                     restoreRate: 50.0 } } }
// Currently-available under requestedQueryCost = HTTP 429. Wait a beat and
// retry once; persist restoreRate math only if you run hot continuously.

Mutations: input types and userErrors

// Mutations take an "input" object type and return userErrors - Shopify's
// own validation channel (NOT exceptions, NOT HTTP status):
const string createCustomer = @"
mutation customerCreate($input: CustomerInput!) {
  customerCreate(input: $input) {
    customer { id email }
    userErrors { field message }
  }
}";
var data2 = await PostGraphAsync(http, createCustomer, new {
    input = new Dictionary<string, object?> {
        ["email"] = "[email protected]",
        ["firstName"] = "Beth",
        ["tags"] = new[] { "vip" },
    }});
// data.customerCreate.userErrors == [] means success; else it is the
// structured "email is not valid"-style list REST returned in "errors".

That is the complete GraphQL tool belt: one endpoint, variables, connections, cost budget, userErrors. Parts 4–6 apply it where it pays the most — orders and draft-order invoices, Shopify Payments transactions and refunds, and fulfillments.