upwork-go

module
v0.0.0-...-3f6ba92 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: MIT

README

Upwork-Go

Go types for the Upwork GraphQL API, generated from Upwork's API documentation.

This is an unofficial, community-maintained package. It is not affiliated with, endorsed by, or supported by Upwork Global Inc.; "Upwork" is a trademark of Upwork Global Inc.

Installation

go get github.com/andrewdevelop/upwork-go

Then import the package(s) you need from v2/..., e.g. github.com/andrewdevelop/upwork-go/v2/entity — v2 here is a plain subdirectory of this module, not a Go modules major-version suffix.

Requires Go 1.25+.

Packages

Package Contents
v2/entity Response entity types (object, enum, scalar types). Support database/sql via Scan/Value for storing entities as JSON columns.
v2/input GraphQL input-object types used as mutation/query arguments (filters, create/update requests, etc).
v2/queries One Go type per top-level GraphQL query field, holding its arguments.
v2/mutations One Go type per top-level GraphQL mutation field, holding its arguments.
v2/gql GraphQL-specific: document/selection-set building for queries/mutations, Execute/Query, and Upwork's errors[] shape (Error, Errors).
v2/transport Protocol-agnostic HTTP client/transport: Client, RetryTransport, TokenRotationTransport, Chain — no GraphQL awareness, reusable for any HTTP call.
v2/auth OAuth 2.0 grants against Upwork's authorization/token endpoints: Authorization Code Grant with PKCE (RFC 7636) and Client Credentials Grant.

Everything under v2/entity, v2/input, v2/queries, and v2/mutations is generated from Upwork's documentation and should not be hand-edited (see the DO NOT EDIT header in each file).

Usage

Building a query/mutation request

Each type in queries/mutations represents one operation's arguments. Fill in the fields you need (required arguments are plain values, optional ones are pointers), then call Document with the response selection set you want, and Variables for the GraphQL variables payload:

import (
    "github.com/andrewdevelop/upwork-go/v2/entity"
    "github.com/andrewdevelop/upwork-go/v2/queries"
)

q := queries.JobPosting{
    JobPostingId: "2056509179207027383",
}

doc := q.Document(`
    id
    version
    content { title description }
`)
vars := q.Variables() // map[string]any, e.g. {"jobPostingId": "2056509179207027383"}

doc is a complete GraphQL document string:

query JobPosting($jobPostingId: ID!, $loadAnnotation: Boolean) {
  jobPosting(jobPostingId: $jobPostingId, loadAnnotation: $loadAnnotation) {
    id
    version
    content { title description }
  }
}

Send doc and vars to Upwork's GraphQL endpoint with any GraphQL/HTTP client, e.g.:

body, _ := json.Marshal(map[string]any{
    "query":     doc,
    "variables": vars,
})
resp, err := http.Post("https://api.upwork.com/graphql", "application/json", bytes.NewReader(body))

Optional arguments are pointers — set them only when needed:

annotate := entity.Boolean(true)
q := queries.JobPosting{
    JobPostingId:   "123",
    LoadAnnotation: &annotate,
}
Deriving the selection set from a Go struct

Writing the selection set as a raw string works, but you can also let it be derived automatically from a Go struct via DocumentFor — define a struct shaped like the response you want (json tags name the fields, same convention as entity), and pass it in:

type jobPostingShape struct {
    ID      string `json:"id"`
    Version int    `json:"version"`
    Content struct {
        Title       string `json:"title"`
        Description string `json:"description"`
    } `json:"content"`
}

doc := q.DocumentFor(jobPostingShape{})

This produces the same kind of document as Document, with the selection set built by walking the struct's fields (nested structs become nested selections, slices/pointers are unwrapped automatically). You can also pass an entity type directly — since those already carry the right json tags — but note that deeply-nested/self-referential entity types (e.g. entity.GenericOrganization.ParentOrganization) will have the cycle cut short at an id field if one exists, or gql.Select (used internally by DocumentFor) panics naming the offending field, telling you to supply a smaller custom struct for it instead. In practice, defining your own small shape struct — as in the example above — is the simplest way to avoid running into that.

Variables are unaffected by DocumentFor. The $jobPostingId: ID! declarations and the jobPosting(jobPostingId: $jobPostingId, ...) call come from the operation's declared arguments (baked in by the generator), independent of whichever shape struct you pass for the selection set. Their values still come from q.Variables(), reading the query/mutation struct (q) itself — not the shape struct:

q := queries.JobPosting{JobPostingId: "123"} // argument values live here

doc := q.DocumentFor(jobPostingShape{}) // selection set only
vars := q.Variables()                   // map[string]any{"jobPostingId": "123"}

body, _ := json.Marshal(map[string]any{"query": doc, "variables": vars})

Mutations work the same way, using types from mutations and, where the mutation takes a structured payload, types from input:

import (
    "github.com/andrewdevelop/upwork-go/v2/input"
    "github.com/andrewdevelop/upwork-go/v2/mutations"
)

m := mutations.UpdateJobPosting{
    Id: "4",
    JobPostingRequest: &input.JobPostingUpdateRequest{
        // ...
    },
}
doc := m.Document("id version")
Decoding responses

Response payloads decode directly into v2/entity types via encoding/json:

var out struct {
    Data struct {
        JobPosting entity.JobPosting `json:"jobPosting"`
    } `json:"data"`
}
json.Unmarshal(respBody, &out)
Calling the API over HTTP

The body/http.Post snippet above is the whole contract — bring your own HTTP client and it works as-is:

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

func run() error {
    q := queries.JobPosting{JobPostingId: "2056509179207027383"}
    doc := q.Document("id version content { title description }")

    body, err := json.Marshal(map[string]any{
        "query":     doc,
        "variables": q.Variables(),
    })
    if err != nil {
        return err
    }

    req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
        "https://api.upwork.com/graphql", bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+accessToken)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var out struct {
        Data struct {
            JobPosting entity.JobPosting `json:"jobPosting"`
        } `json:"data"`
        Errors []struct {
            Message string `json:"message"`
        } `json:"errors"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return err
    }
    if len(out.Errors) > 0 {
        return fmt.Errorf("graphql: %s", out.Errors[0].Message)
    }

    fmt.Println(out.Data.JobPosting.Content.Title)
    return nil
}
gql.Execute — a lightweight facade over your own *http.Client

gql.Execute is a plain function built on net/http — no client type of its own to reach for. You pass your own *http.Client (whatever Transport, timeout, or retry policy you use elsewhere — see v2/transport for ready-made ones), and it derives the selection set from a shape struct (via DocumentFor), sends the request, unwraps the operation's field out of data, and decodes it into that shape:

import (
    "context"
    "net/http"

    "github.com/andrewdevelop/upwork-go/v2/gql"
    "github.com/andrewdevelop/upwork-go/v2/queries"
)

type jobPostingShape struct {
    ID      string `json:"id"`
    Version int    `json:"version"`
    Content struct {
        Title string `json:"title"`
    } `json:"content"`
}

header := http.Header{"Authorization": []string{"Bearer " + accessToken}}

result, err := gql.Execute(context.Background(), http.DefaultClient,
    "https://api.upwork.com/graphql", header,
    queries.JobPosting{JobPostingId: "2056509179207027383"},
    jobPostingShape{},
)
if err != nil {
    // A non-nil error here can be transport/decode failure, or a
    // gql.Errors value from the GraphQL response's "errors" array.
}
fmt.Println(result.Content.Title)

Pass nil instead of an *http.Client to use http.DefaultClient, or your own instance configured however your project already configures outbound HTTP calls.

gql.NewClient(httpClient) / gql.Query(ctx, client, op, shape) are the higher-level counterpart, if you'd rather hold onto a reusable, immutable client than pass endpoint/header to Execute by hand every call — gql.NewClient returns a *transport.Client (see below) pre-set to gql.DefaultEndpoint.

GraphQL error handling — why a 200 OK can still be an error

Upwork's GraphQL API always answers with HTTP 200, even when the operation failed — a non-2xx status only ever means the request failed before reaching the GraphQL layer (auth, a 503, ...; see transport.StatusError, returned by Execute/gql.Query for that case). Otherwise, failures come back as a gql.Errors value decoded from the response body's "errors" array — check for it with errors.As:

result, err := gql.Query(ctx, client, someQuery, someShape{})
var gqlErrs gql.Errors
switch {
case errors.As(err, &gqlErrs):
    if gqlErrs.HasClassification(gql.ClassificationValidationError) {
        // malformed query: missing/null/wrongly-typed argument, etc.
    } else if gqlErrs.IsPermissionDenied() {
        // access token lacks the oauth2 scope for a requested field
    }
case err != nil:
    // transport/decode failure, or a *transport.StatusError
}

Upwork's API is inconsistent about how much structure an error carries: a query-validation failure includes Locations and Extensions["classification"] (gql.Error.Classification(), gql.ClassificationValidationError); an authorization failure includes neither, just a Message naming the missing scopes — Errors.IsPermissionDenied matches that case by wording, as a best-effort convenience rather than a contract (Upwork could change the message).

Storing entities in a database

v2/entity types implement sql.Scanner/driver.Valuer (JSON-encoded), so they can be read from and written to a JSON/JSONB column directly:

var jp entity.JobPosting
db.QueryRow(`SELECT payload FROM job_postings WHERE id = $1`, id).Scan(&jp)

db.Exec(`INSERT INTO job_postings (id, payload) VALUES ($1, $2)`, jp.ID, jp)

A handful of types (BillRate, JobPostingCustomFields, StringMapElement, WorkflowTaskCustomAttribute) have their own Value field, which collides with the Value() method Go requires for driver.Valuer. These implement Scan only; encode them for writes with entity package internals or json.Marshal directly.

Authentication (v2/auth)

v2/auth implements Upwork's OAuth 2.0 grants, one type per grant so the package has room to grow (see its package doc for what's planned beyond what's below — an Implicit Grant). Two grants are implemented today.

The Authorization Code Grant with PKCE is the flow Upwork recommends over the deprecated Implicit Grant, for anything acting on behalf of a logged-in Upwork user:

import (
    "context"
    "net/http"

    "github.com/andrewdevelop/upwork-go/v2/auth"
)

grant := auth.AuthorizationCodeGrant{
    ClientID:     "YOUR_CLIENT_ID",
    ClientSecret: "YOUR_CLIENT_SECRET",
    RedirectURI:  "https://example.com/oauth/callback",
}

// Step 1: generate a PKCE pair and CSRF state, then redirect the user.
pkce, err := auth.NewPKCE()
state, err := auth.NewState()
redirectURL := grant.AuthorizationURL(state, pkce.Challenge)
// Persist pkce.Verifier and state (e.g. in the user's session) until the callback.

// Step 2: in the callback handler, verify the "state" query parameter
// matches what you stored, then exchange the "code" query parameter.
token, err := grant.Exchange(context.Background(), http.DefaultClient, code, pkce.Verifier)
// token.AccessToken, token.RefreshToken, token.ExpiresIn (seconds)

// Later, once the access token expires:
token, err = grant.Refresh(context.Background(), http.DefaultClient, token.RefreshToken)

Exchange and Refresh accept any *http.Client (nil uses http.DefaultClient), consistent with gql.Execute.

The Client Credentials Grant is server-to-server, with no redirect and no PKCE — a client ID/secret pair exchanges directly for an access token. It's only available to Upwork Enterprise consumers, and the response carries no refresh token; request a new one with another Token call once the current one's ExpiresIn runs out:

grant := auth.ClientCredentialsGrant{
    ClientID:     "YOUR_CLIENT_ID",
    ClientSecret: "YOUR_CLIENT_SECRET",
}
token, err := grant.Token(context.Background(), http.DefaultClient)

This is also what backs Upwork's Service Accounts: apply for a Client Credentials key with the "create a Client Credential key for Service Account" option, and Upwork associates it with a Service Account on its side — the token request itself is identical, ClientCredentialsGrant as above. Assign scopes/permissions to the Service Account in Upwork's dashboard; Upwork's docs describe Service Accounts as intended for read-only use.

HTTP transport (v2/transport)

v2/transport is protocol-agnostic HTTP client/transport machinery — an authenticated Client plus a set of composable http.RoundTripper decorators. None of it knows anything about GraphQL; v2/gql builds Upwork's GraphQL client on top of transport.Client (gql.NewClient, gql.Execute, gql.Query), but everything here works just as well for a plain REST call.

transport.TokenRotationTransport attaches Authorization: Bearer <token> to every request made through the *http.Client it's installed on, and on a 401 refreshes the token via a grant and retries once. Concurrent 401s coalesce onto a single refresh call:

httpClient := &http.Client{
    Transport: transport.NewTokenRotationTransport(transport.NewRetryTransport(nil), grant, token,
        func(ctx context.Context, refreshed *auth.Token) error {
            return sessionStore.Save(ctx, sessionID, refreshed)
        }),
}
client := gql.NewClient(httpClient)

For more than two links, transport.Chain reads better than nesting constructor calls — the last middleware given ends up outermost (first to see the request), so top-to-bottom order in the call matches request-flow order:

rt := transport.Chain(nil,
    func(next http.RoundTripper) http.RoundTripper { return transport.NewRetryTransport(next) },
    func(next http.RoundTripper) http.RoundTripper {
        return transport.NewTokenRotationTransport(next, grant, token, onRefresh)
    },
    // ...your own logging/tracing http.RoundTripper, etc.
)

transport.RetryTransport is a standard http.RoundTripper that retries transient failures — network errors, and by default any 5xx status (a 502/504 from a proxy in front of an API is as common as a 500/503 from the origin itself) — with exponential backoff and jitter, honoring a Retry-After header when present:

httpClient := &http.Client{Transport: transport.NewRetryTransport(nil)} // 3 retries, 200ms–5s backoff, any 5xx
client := gql.NewClient(httpClient)

Set RetryStatus to retry a different set of codes instead — narrower (only 503), wider (add 429 Too Many Requests), or anything else func(status int) bool can express:

rt := transport.NewRetryTransport(nil)
rt.RetryStatus = func(status int) bool {
    return status == http.StatusTooManyRequests || transport.DefaultRetryStatus(status)
}

It only retries a request whose body it can safely resend (none, or one net/http can auto-replay via GetBody — true for gql.Execute's own requests), and composes with any other http.RoundTripper you layer on top. Want different retry logic, or none at all? Just don't use it — nothing else in v2/transport depends on it.

Neither decorator is wired into gql.Query automatically — it does no retrying of its own, by design: configure retry/rotation once on the *http.Client's Transport, and it applies uniformly to every request made through it. See examples/basic for a full runnable OAuth2 + GraphQL web app built on transport.Client, TokenRotationTransport, and RetryTransport together.

Notes

  • v2/input covers only the input-object types actually referenced (directly or transitively) by the generated queries/mutations — not the full input-object surface of the schema.
  • Query/mutation Document does not hardcode a response selection set: Upwork's docs use unresolvable ...FragmentName placeholders for nested objects in several examples, so the caller supplies the selection explicitly.

License

MIT

Directories

Path Synopsis
examples
basic command
Command basic is a runnable example of the Upwork OAuth2 + GraphQL flow: AuthServer (auth_server.go) runs the Authorization Code Grant with PKCE and, per session, assembles a transport.Chain of a retry link (a transient 5xx or network error gets retried with backoff) and a transport.TokenRotationTransport link (a 401 rotates the access token and retries once, coalescing concurrent rotations) — both from v2/transport, which knows nothing about GraphQL.
Command basic is a runnable example of the Upwork OAuth2 + GraphQL flow: AuthServer (auth_server.go) runs the Authorization Code Grant with PKCE and, per session, assembles a transport.Chain of a retry link (a transient 5xx or network error gets retried with backoff) and a transport.TokenRotationTransport link (a 401 rotates the access token and retries once, coalescing concurrent rotations) — both from v2/transport, which knows nothing about GraphQL.
pkce-check command
Command pkce-check is a small interactive tool to verify that the Authorization Code Grant with PKCE (v2/auth.AuthorizationCodeGrant) actually works end-to-end against Upwork's real endpoints, using the UPWORK_CLIENT_ID / UPWORK_CLIENT_SECRET / UPWORK_REDIRECT_URI values from the repo-root .env file.
Command pkce-check is a small interactive tool to verify that the Authorization Code Grant with PKCE (v2/auth.AuthorizationCodeGrant) actually works end-to-end against Upwork's real endpoints, using the UPWORK_CLIENT_ID / UPWORK_CLIENT_SECRET / UPWORK_REDIRECT_URI values from the repo-root .env file.
v2
auth
Package auth implements Upwork's OAuth 2.0 grants — the different ways a client can end up with a Token.
Package auth implements Upwork's OAuth 2.0 grants — the different ways a client can end up with a Token.
entity
Package entity contains Go data types representing the Upwork GraphQL API entity model.
Package entity contains Go data types representing the Upwork GraphQL API entity model.
gql
Package gql provides the shared machinery behind the generated query and mutation types in v2/queries and v2/mutations: assembling a GraphQL document string for an operation and extracting its variables from a Go struct.
Package gql provides the shared machinery behind the generated query and mutation types in v2/queries and v2/mutations: assembling a GraphQL document string for an operation and extracting its variables from a Go struct.
input
Package input contains Go data types representing the Upwork GraphQL API input-object types (mutation and query arguments).
Package input contains Go data types representing the Upwork GraphQL API input-object types (mutation and query arguments).
mutations
Package mutations contains Go types for the Upwork GraphQL API's top-level mutation operations, generated from the Upwork GraphQL API documentation (docs.html).
Package mutations contains Go types for the Upwork GraphQL API's top-level mutation operations, generated from the Upwork GraphQL API documentation (docs.html).
queries
Package queries contains Go types for the Upwork GraphQL API's top-level query operations, generated from the Upwork GraphQL API documentation (docs.html).
Package queries contains Go types for the Upwork GraphQL API's top-level query operations, generated from the Upwork GraphQL API documentation (docs.html).
transport
Package transport is protocol-agnostic HTTP client/transport machinery: an authenticated Client and a set of composable http.RoundTripper decorators (RetryTransport, TokenRotationTransport, Chain).
Package transport is protocol-agnostic HTTP client/transport machinery: an authenticated Client and a set of composable http.RoundTripper decorators (RetryTransport, TokenRotationTransport, Chain).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL