Files
2026-07-15 14:59:56 -05:00

5.5 KiB

API Layer Architecture

Overview

Wrapper-based API abstraction built on inaturalistjs, combined with React Query for state management. All API responses are transformed to match Realm schemas before storage. Custom error handling with retry strategies and Grafana logging.

Key Files

File Purpose
src/api/observations.js Observation CRUD, search, faves, subscriptions (see its export block for the full list)
src/api/taxa.js Taxon fetch and search with field optimization
src/api/users.js User profiles, blocking/muting
src/api/projects.js Project search, membership
src/api/search.ts Cross-resource search
src/api/error.ts Custom error classes (INatApiError, 401, 429) and handleError
src/api/types.d.ts TypeScript interfaces for API responses
src/api/log/index.ts iNatLogstashTransport — posts logs to the iNaturalist API log endpoint (surfaced downstream in Grafana)
src/sharedHooks/useAuthenticatedQuery.ts React Query wrapper with JWT injection
src/sharedHooks/useAuthenticatedMutation.ts Mutation wrapper with JWT + 401/429 handling
src/sharedHooks/useAuthenticatedInfiniteQuery.ts Infinite query wrapper for pagination
src/sharedHooks/useQuery.ts Non-authenticated query wrapper
src/sharedHelpers/logging.js Retry logic and delay strategies

Standard API Wrapper Pattern

Every API module follows this pattern:

import inatjs from "inaturalistjs";
import handleError from "./error";

const searchObservations = async ( params = {}, opts = {} ) => {
  try {
    const response = await inatjs.observations.search( params, opts );
    response.results = response.results.map( mapToLocalSchema );
    return response;
  } catch ( e ) {
    return handleError( e, { context: { functionName: "searchObservations" } } );
  }
};

Rules:

  • ALL calls go through wrapper functions (never call inaturalistjs directly from components)
  • Every call wrapped in try-catch with handleError
  • Responses transformed to Realm-compatible schema before returning
  • params = query/body data, opts = options including api_token

React Query Hooks

useAuthenticatedQuery

const { data, error, isLoading, refetch } = useAuthenticatedQuery(
  ["fetchTaxon", taxonId],
  optsWithAuth => fetchTaxon( taxonId, params, optsWithAuth ),
  { enabled },
);
  • Waits for auth state before executing
  • Injects JWT via getJWT(allowAnonymousJWT)
  • Includes auth state in query key for cache separation
  • Custom retry logic per status code

useAuthenticatedMutation

const { mutate } = useAuthenticatedMutation(
  ( faveParams, optsWithAuth ) => faveObservation( faveParams, optsWithAuth ),
  { onSuccess: () => { /* ... */ }, onError: ( error ) => { /* ... */ } },
);
mutate( { uuid } );
  • Auto-injects JWT
  • Smart 401 handling: logs context, attempts token refresh
  • Smart 429 handling: logs the error (no automatic retry/backoff on mutations — exponential backoff applies to the query path only; see logging.js)

useAuthenticatedInfiniteQuery

const { data, fetchNextPage, isFetchingNextPage } = useAuthenticatedInfiniteQuery(
  queryKey,
  async ( params, optsWithAuth ) => searchObservations( { ...baseParams, ...params }, optsWithAuth ),
  { getNextPageParam, enabled },
);
const observations = flatten( data?.pages?.map( r => r.results ) ) || [];

useNonAuthenticatedQuery (useQuery.ts)

For public endpoints that don't need JWT.

Query Key Conventions

Format: [domain, ...identifiers]

["fetchTaxon", taxonId]
["fetchQualityMetrics", observationUUID]
["useInfiniteExploreScroll", { ...filterParams }]
["fetchSearchResults", locationName]

Auth state appended automatically by hooks: [...queryKey, allowAnonymousJWT, userLoggedIn]

Response Transformation

Two-stage process:

  1. API wrapper normalizes common fields (e.g., license_codelicenseCode)
  2. Realm model mapper applies domain logic (e.g., Taxon.mapApiToRealm())

Error Handling

Error Classes (src/api/error.ts)

  • INatApiError — Base class with json, status, context
  • INatApiUnauthorizedError — 401 errors
  • INatApiTooManyRequestsError — 429 errors

Retry Strategy (src/sharedHelpers/logging.js)

Status Behavior
429 (Rate limit) Exponential backoff + jitter, retry 3x
401/403 (Auth) Token refresh attempt, retry 2x
404 (Not found) No retry
408 / Network error Retry 3x
Others Retry 2x

Retry Delay

  • 429: 1000 * 2^failureCount + random(0-100)ms
  • Others: min(1000 * 2^failureCount, 30000ms)

Field Filtering

API calls specify exact fields to reduce payload:

const FIELDS = {
  ancestor_ids: true,
  default_photo: { url: true },
  name: true,
  preferred_common_name: true,
  rank: true,
  rank_level: true,
};

Common Operations

Adding a new API endpoint

  1. Create wrapper function in appropriate src/api/*.js file following the standard pattern
  2. Create React Query hook using useAuthenticatedQuery or useAuthenticatedMutation
  3. Transform response to match Realm schema if persisting locally
  4. Add TypeScript types to src/api/types.d.ts if needed

Adding a new React Query hook

  1. Choose the right base hook: useAuthenticatedQuery (read), useAuthenticatedMutation (write), useAuthenticatedInfiniteQuery (paginated list)
  2. Define a descriptive query key including relevant identifiers
  3. Pass the API wrapper function with auth options
  4. Handle errors in onError callback for mutations