npm package: @instructure/platform-canvas-fetch
Description: Canvas-aware fetch wrapper with TypeScript and Zod validation support.
Latest version: 0.1.3

# @instructure/platform-canvas-fetch

Canvas-aware fetch wrapper with TypeScript and Zod validation support.

## Features

- Automatic CSRF token handling from cookies
- Smart body handling (JSON, FormData, strings)
- Mandatory Zod schema validation
- Automatic Link header parsing for pagination
- Throws on HTTP errors (4xx/5xx)
- TypeScript-first with full type inference
- Canvas API conventions (query params, headers)

## Installation

```bash
pnpm add @instructure/platform-canvas-fetch zod
```

## Basic Usage

```typescript
import { canvasFetch } from '@instructure/platform-canvas-fetch'
import { z } from 'zod'

// Define your response schema
const SubmissionSchema = z.object({
  id: z.string(),
  user_id: z.string(),
  assignment_id: z.string(),
  sticker: z.string().nullable()
})

// Make a request
const { data, response, link } = await canvasFetch(
  {
    path: '/api/v1/courses/123/assignments/456/submissions/789',
    method: 'GET'
  },
  SubmissionSchema
)

// data is fully typed and validated
console.log(data.sticker) // string | null
```

## API

### `canvasFetch(options, schema)`

Main fetch function with Zod validation.

**Options:**
- `path` (string, required) - API endpoint path
- `method` ('GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE') - HTTP method (default: 'GET')
- `body` (object | FormData | string) - Request body
- `params` (object) - Query parameters (encoded with Canvas conventions)
- `headers` (object) - Additional headers to merge with defaults
- `signal` (AbortSignal) - For request cancellation

**Returns:** `Promise<CanvasFetchResult<T>>`
- `data` - Validated response data (typed from Zod schema)
- `response` - Raw Response object
- `link` - Parsed Link headers (for pagination)

**Throws:** `CanvasFetchError` on HTTP errors or validation failures

### `configureCanvasFetch(config)`

Configure global options.

```typescript
import { configureCanvasFetch } from '@instructure/platform-canvas-fetch'

configureCanvasFetch({
  baseUrl: 'https://canvas.instructure.com'
})
```

## Examples

### POST Request with Body

```typescript
const NewSubmissionSchema = z.object({
  id: z.string(),
  workflow_state: z.string()
})

const { data } = await canvasFetch(
  {
    path: '/api/v1/courses/123/assignments/456/submissions',
    method: 'POST',
    body: {
      submission: {
        submission_type: 'online_text_entry',
        body: 'My submission'
      }
    }
  },
  NewSubmissionSchema
)
```

### Query Parameters (Canvas-style)

```typescript
const { data } = await canvasFetch(
  {
    path: '/api/v1/courses/123/assignments',
    params: {
      per_page: 50,
      include: ['submission', 'score_statistics'], // Encoded as include[]=...
      order_by: 'due_at'
    }
  },
  AssignmentsSchema
)
// Request: /api/v1/courses/123/assignments?per_page=50&include[]=submission&include[]=score_statistics&order_by=due_at
```

### Pagination with TanStack Query

```typescript
import { useInfiniteQuery } from '@tanstack/react-query'

const AssignmentSchema = z.object({
  id: z.string(),
  name: z.string()
})

const AssignmentsSchema = z.array(AssignmentSchema)

function useAssignments(courseId: string) {
  return useInfiniteQuery({
    queryKey: ['assignments', courseId],
    queryFn: async ({ pageParam }) => {
      return canvasFetch(
        {
          path: pageParam || `/api/v1/courses/${courseId}/assignments`,
          params: { per_page: 50 }
        },
        AssignmentsSchema
      )
    },
    getNextPageParam: (lastPage) => lastPage.link?.next?.url,
    initialPageParam: undefined
  })
}
```

### Error Handling

```typescript
import { CanvasFetchError } from '@instructure/platform-canvas-fetch'

try {
  const { data } = await canvasFetch({ path: '/api/v1/...' }, Schema)
} catch (error) {
  if (error instanceof CanvasFetchError) {
    console.error('HTTP Status:', error.status)
    console.error('Response:', error.response)
    console.error('Error Body:', error.json)
  }
}
```

### Custom Headers

```typescript
const { data } = await canvasFetch(
  {
    path: '/api/v1/courses',
    headers: {
      'Accept': 'application/json+canvas-string-ids', // Opt-in to string IDs
      'X-Custom-Header': 'value'
    }
  },
  CoursesSchema
)
```

### Request Cancellation

```typescript
const controller = new AbortController()

const request = canvasFetch(
  {
    path: '/api/v1/courses',
    signal: controller.signal
  },
  CoursesSchema
)

// Cancel the request
controller.abort()
```

## Default Headers

Automatically included on every request:

- `X-Requested-With: XMLHttpRequest`
- `X-CSRF-Token: [from _csrf_token cookie]`
- `Accept: application/json`
- `credentials: same-origin`

## Security Considerations

### Error Response Data

When `CanvasFetchError` is thrown, the `error.json` property contains the raw response body from the server. **Do not render this data directly to the DOM without proper sanitization**, as it may contain user-generated content or malicious payloads that could lead to XSS vulnerabilities.

**Safe:**
```typescript
try {
  await canvasFetch({ path: '/api/v1/...' }, Schema)
} catch (error) {
  if (error instanceof CanvasFetchError) {
    // Log for debugging - safe
    console.error('API Error:', error.json)

    // Display generic message - safe
    showErrorToast('An error occurred. Please try again.')
  }
}
```

**Unsafe:**
```typescript
try {
  await canvasFetch({ path: '/api/v1/...' }, Schema)
} catch (error) {
  if (error instanceof CanvasFetchError) {
    // ❌ DANGEROUS - could inject malicious HTML/scripts
    element.innerHTML = error.json.message
  }
}
```

Always sanitize user-facing error messages or use a framework's built-in XSS protection (e.g., React's JSX automatically escapes).