Skip to content

Architecture

canvas-cli is a layered Go CLI. Commands stay thin. Canvas API behavior lives in shared packages.

Package layout

text
cmd/canvas/
  main.go
internal/cli/
  root.go
  auth.go
  me.go
  courses.go
  assignments.go
  modules.go
  files.go
  discussions.go
  inbox.go
  submissions.go
  grading.go
  pages.go
  api.go
internal/config/
  config.go
internal/canvas/
  client.go
  request.go
  pagination.go
  ratelimit.go
  errors.go
  uploads.go
  types.go
  courses.go
  assignments.go
  modules.go
  files.go
  submissions.go
  discussions.go
  conversations.go
  pages.go
  announcements.go
  grading.go
internal/output/
  envelope.go
  json.go
  table.go
  exitcode.go
internal/safety/
  policy.go
  dryrun.go
internal/audit/
  audit.go
internal/testutil/
  canvas_mock.go

Dependency direction

text
cmd/canvas -> internal/cli -> internal/canvas
                           -> internal/config
                           -> internal/output
                           -> internal/safety
                           -> internal/audit

internal/canvas must not depend on internal/cli.

HTTP client

Use net/http and make the transport testable.

Required behavior:

  • configurable base URL
  • bearer token auth
  • explicit user agent
  • request timeout
  • context cancellation
  • default single-flight request concurrency
  • automatic pagination helper
  • Link header parser
  • retry/backoff for throttling and transient failures
  • error body preservation
  • response metadata capture
  • token redaction

Default headers:

text
Authorization: Bearer <token>
User-Agent: canvas-cli/<version> (+https://github.com/thedavidweng/canvas-cli)
Accept: application/json+canvas-string-ids

Use Canvas string IDs by default to preserve 64-bit IDs safely in downstream JSON tools.

Request model

Define a reusable request function:

go
type RequestOptions struct {
    Method      string
    PathOrURL   string
    Query       url.Values
    Body        io.Reader
    Headers     http.Header
    Paginate    bool
    PageSize    int
    Limit       int
    DecodeInto  any
}

Commands should use resource-specific methods where possible:

go
client.ListCourses(ctx, opts)
client.ListAssignments(ctx, courseID, opts)
client.SubmitAssignment(ctx, courseID, assignmentID, submission)

api get may use the lower-level request path directly.

Pagination model

List commands should auto-paginate by default. The client must treat Link header URLs as opaque absolute URLs.

Pagination metadata should include:

  • paginated
  • page_size
  • limit
  • request_count
  • next_url_present
  • total_items_returned

Rate-limit model

Canvas uses dynamic request cost and remaining quota headers. The client should inspect:

  • X-Request-Cost
  • X-Rate-Limit-Remaining
  • Retry-After

Retry behavior:

  • retry 429
  • retry transient 5xx
  • optionally treat 403 bodies that clearly indicate rate-limit exhaustion as retryable compatibility cases
  • respect Retry-After when present
  • use bounded exponential backoff with jitter when absent
  • cap retry count

Output model

Every command must return a typed result to the output layer. Commands should not print arbitrary JSON themselves.

Human mode:

  • concise tables or lists
  • no decorative noise
  • warnings to stderr

JSON mode:

  • stable envelope
  • data under data
  • execution metadata under meta
  • errors under error

Mutation model

Any remote write must go through safety and audit layers.

Remote writes include:

  • assignment submission
  • discussion post/reply
  • inbox message send/reply
  • grade update
  • submission comment
  • file upload
  • course/module/page/assignment/discussion update
  • publish/unpublish changes
  • due date changes

Required write flow:

  1. build operation plan
  2. validate permissions as much as possible
  3. show dry-run preview when requested
  4. require confirmation unless policy allows non-interactive execution
  5. execute mutation
  6. capture Canvas response metadata
  7. write local audit JSONL event
  8. emit stable JSON envelope or human result

File upload model

Canvas file uploads may use a multi-step upload flow. Implement a shared upload helper before implementing file submissions, bulk uploads, or submission comment attachments.

The helper should support:

  • local file path validation
  • size and MIME detection
  • upload session creation
  • upload transfer
  • finalization response parsing
  • checksum metadata where useful
  • progress only on stderr
  • no progress in JSON mode unless explicitly requested through events

Permissions model

Canvas API access depends on user role, course role, account-level permissions, and endpoint-specific permissions. Commands should assume permission may be missing.

Permission errors are normalized:

json
{
  "code": "CANVAS_PERMISSION_DENIED",
  "category": "permission",
  "status": 403,
  "retryable": false
}

Raw API escape hatch

canvas api get, api post, api put, and api delete are available. Write operations go through safety gates.

Raw API commands should support:

  • path or absolute URL under same base URL
  • query params
  • headers display in JSON mode
  • one-page default
  • --paginate optional
  • --raw body output

Core type definitions

The following Go structs define the shapes that all packages share. IDs are string to match Canvas string-ID mode.

JSON envelope

go
// Envelope is the top-level JSON output wrapper.
type Envelope struct {
    OK    bool        `json:"ok"`
    Data  interface{} `json:"data,omitempty"`
    Error *ErrorInfo  `json:"error,omitempty"`
    Meta  Meta        `json:"meta"`
}

type Meta struct {
    SchemaVersion string     `json:"schema_version"`
    Command       string     `json:"command"`
    Profile       string     `json:"profile,omitempty"`
    BaseURL       string     `json:"base_url,omitempty"`
    DurationMS    int64      `json:"duration_ms,omitempty"`
    RequestCount  int        `json:"request_count,omitempty"`
    Paginated     bool       `json:"paginated,omitempty"`
    PageSize      int        `json:"page_size,omitempty"`
    Limit         *int       `json:"limit"`
    RateLimit     *RateLimit `json:"rate_limit,omitempty"`
    Warnings      []string   `json:"warnings,omitempty"`
}

type RateLimit struct {
    RequestCost float64 `json:"request_cost"`
    Remaining   float64 `json:"remaining"`
}

type ErrorInfo struct {
    Code            string      `json:"code"`
    Message         string      `json:"message"`
    Category        string      `json:"category"`
    Retryable       bool        `json:"retryable"`
    Status          int         `json:"status,omitempty"`
    CanvasRequestID string      `json:"canvas_request_id,omitempty"`
    ResponseBody    interface{} `json:"response_body,omitempty"`
}

Canvas domain types

go
type Course struct {
    ID               string       `json:"id"`
    Name             string       `json:"name"`
    CourseCode       string       `json:"course_code"`
    WorkflowState    string       `json:"workflow_state"`
    EnrollmentTermID string       `json:"enrollment_term_id"`
    Term             *Term        `json:"term,omitempty"`
    Enrollments      []Enrollment `json:"enrollments,omitempty"`
}

type Term struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

type Assignment struct {
    ID                      string   `json:"id"`
    CourseID                string   `json:"course_id"`
    Name                    string   `json:"name"`
    DescriptionHTML         string   `json:"description_html,omitempty"`
    DueAt                   *string  `json:"due_at"`
    UnlockAt                *string  `json:"unlock_at"`
    LockAt                  *string  `json:"lock_at"`
    Published               bool     `json:"published"`
    PointsPossible          float64  `json:"points_possible"`
    SubmissionTypes          []string `json:"submission_types"`
    HasSubmittedSubmissions  bool     `json:"has_submitted_submissions"`
}

type Submission struct {
    ID             string       `json:"id"`
    UserID         string       `json:"user_id"`
    AssignmentID   string       `json:"assignment_id"`
    Score          *float64     `json:"score"`
    Grade          *string      `json:"grade"`
    SubmittedAt    *string      `json:"submitted_at"`
    WorkflowState  string       `json:"workflow_state"`
    Late           bool         `json:"late"`
    Missing        bool         `json:"missing"`
    Excused        bool         `json:"excused"`
    Attempt        *int         `json:"attempt"`
    Attachments    []Attachment `json:"attachments,omitempty"`
    User           *User        `json:"user,omitempty"`
}

type Attachment struct {
    ID          string `json:"id"`
    Filename    string `json:"filename"`
    DisplayName string `json:"display_name"`
    URL         string `json:"url"`
    Size        int64  `json:"size"`
    ContentType string `json:"content_type"`
}

type User struct {
    ID           string  `json:"id"`
    Name         string  `json:"name"`
    SortableName string  `json:"sortable_name"`
    ShortName    string  `json:"short_name"`
    Email        *string `json:"email,omitempty"`
    LoginID      string  `json:"login_id,omitempty"`
}

type Enrollment struct {
    ID                  string  `json:"id"`
    UserID              string  `json:"user_id"`
    CourseID            string  `json:"course_id"`
    Type                string  `json:"type"`
    EnrollmentState     string  `json:"enrollment_state"`
    Role                string  `json:"role"`
    Grades              *Grades `json:"grades,omitempty"`
}

type Grades struct {
    CurrentScore *float64 `json:"current_score"`
    FinalScore   *float64 `json:"final_score"`
    CurrentGrade *string  `json:"current_grade"`
    FinalGrade   *string  `json:"final_grade"`
}

type Module struct {
    ID               string `json:"id"`
    Name             string `json:"name"`
    Position         int    `json:"position"`
    Published        bool   `json:"published"`
    ItemsCount       int    `json:"items_count"`
    WorkflowState    string `json:"workflow_state"`
}

type ModuleItem struct {
    ID         string  `json:"id"`
    ModuleID   string  `json:"module_id"`
    Title      string  `json:"title"`
    Type       string  `json:"type"`
    Position   int     `json:"position"`
    ContentID  string  `json:"content_id,omitempty"`
    HTMLURL    string  `json:"html_url,omitempty"`
    URL        *string `json:"url,omitempty"`
    Published  *bool   `json:"published,omitempty"`
}

type DiscussionTopic struct {
    ID              string  `json:"id"`
    Title           string  `json:"title"`
    Message         string  `json:"message"`
    PostedAt        *string `json:"posted_at"`
    LastReplyAt     *string `json:"last_reply_at"`
    DiscussionType  string  `json:"discussion_type"`
    Published       bool    `json:"published"`
    IsAnnouncement  bool    `json:"is_announcement"`
    UserName        string  `json:"user_name,omitempty"`
}

type Page struct {
    URL          string  `json:"url"`
    Title        string  `json:"title"`
    Body         string  `json:"body,omitempty"`
    Published    bool    `json:"published"`
    CreatedAt    string  `json:"created_at"`
    UpdatedAt    string  `json:"updated_at"`
}

type Conversation struct {
    ID              string   `json:"id"`
    Subject         string   `json:"subject"`
    WorkflowState   string   `json:"workflow_state"`
    LastMessage     string   `json:"last_message"`
    LastMessageAt   string   `json:"last_message_at"`
    MessageCount    int      `json:"message_count"`
    Participants    []User   `json:"participants,omitempty"`
}

type File struct {
    ID          string `json:"id"`
    FolderID    string `json:"folder_id"`
    DisplayName string `json:"display_name"`
    Filename    string `json:"filename"`
    URL         string `json:"url"`
    Size        int64  `json:"size"`
    ContentType string `json:"content_type"`
    CreatedAt   string `json:"created_at"`
    UpdatedAt   string `json:"updated_at"`
}

type Section struct {
    ID            string `json:"id"`
    Name          string `json:"name"`
    CourseID      string `json:"course_id"`
    TotalStudents *int   `json:"total_students,omitempty"`
}

type Rubric struct {
    ID             string        `json:"id"`
    Title          string        `json:"title"`
    PointsPossible float64       `json:"points_possible"`
    Criteria       []interface{} `json:"criteria,omitempty"`
}

type AssignmentGroup struct {
    ID          string       `json:"id"`
    Name        string       `json:"name"`
    Position    int          `json:"position"`
    GroupWeight float64      `json:"group_weight"`
    Assignments []Assignment `json:"assignments,omitempty"`
}

Config types

go
type Config struct {
    CurrentProfile string             `yaml:"current_profile"`
    Profiles       map[string]Profile `yaml:"profiles"`
    Output         OutputConfig       `yaml:"output,omitempty"`
    Audit          AuditConfig        `yaml:"audit,omitempty"`
}

type Profile struct {
    BaseURL       string `yaml:"base_url"`
    Token         string `yaml:"token"`
    Timeout       string `yaml:"timeout,omitempty"`
    Retries       int    `yaml:"retries,omitempty"`
    PageSize      int    `yaml:"page_size,omitempty"`
    ReadOnly      bool   `yaml:"read_only,omitempty"`
    DefaultCourse string `yaml:"default_course,omitempty"`
}

type OutputConfig struct {
    JSONPretty bool `yaml:"json_pretty"`
    NoColor    bool `yaml:"no_color"`
}

type AuditConfig struct {
    Enabled bool   `yaml:"enabled"`
    Path    string `yaml:"path,omitempty"`
}

Audit event type

go
type AuditEvent struct {
    Time            string            `json:"time"`
    SchemaVersion   string            `json:"schema_version"`
    Command         string            `json:"command"`
    Profile         string            `json:"profile"`
    BaseURL         string            `json:"base_url"`
    Method          string            `json:"method"`
    Path            string            `json:"path"`
    Resource        map[string]string `json:"resource"`
    RequestHash     string            `json:"request_hash"`
    ResponseStatus  int               `json:"response_status"`
    CanvasRequestID string            `json:"canvas_request_id,omitempty"`
    DryRun          bool              `json:"dry_run"`
    Success         bool              `json:"success"`
}

Built with VitePress