Skip to content

Implement HTTP API client with auth and error handling#30

Merged
ran-codes merged 1 commit intomainfrom
issue-5/api-client
Feb 17, 2026
Merged

Implement HTTP API client with auth and error handling#30
ran-codes merged 1 commit intomainfrom
issue-5/api-client

Conversation

@ran-codes
Copy link
Copy Markdown
Owner

ELI5

Every command that talks to Zenodo needs to send HTTP requests — add the right auth header, send/receive JSON, and understand error responses. This PR builds a shared HTTP client so each command doesn't have to reinvent that wheel. You call client.Get("/records/123", ...) and it handles auth, JSON parsing, and turns Zenodo errors into clear messages like "401: check your token" or "403: you need the deposit:write scope."

Summary

  • Shared Client struct with Get(), Post(), Put(), Delete() JSON helpers
  • GetRaw() for non-JSON formats (BibTeX, DataCite XML) with custom Accept headers
  • Bearer token auth injection (omitted when no token set)
  • APIError struct parses Zenodo's structured error responses with field-level details
  • User-friendly hints for common HTTP errors (401, 403, 404, 429)
  • Graceful handling of non-JSON error bodies and 204 No Content

Code changes

File What
internal/api/client.go Client struct, NewClient(), Get/Post/Put/Delete/GetRaw, parseAPIError()
internal/model/error.go APIError struct with Error() and Hint() methods
internal/api/client_test.go 11 tests: GET/POST/PUT/DELETE success, auth header, no-auth, query params, structured errors, 401 hint, non-JSON errors, raw response

Test plan

  • go test ./internal/api/ -v — all 11 tests pass
  • go test ./... — all 26 tests pass across all packages
  • go build ./cmd/zenodo/ compiles

Closes #5

🤖 Generated with Claude Code

Add the shared HTTP client that all API operations will use.

- Client struct with Get/Post/Put/Delete JSON helpers
- GetRaw for non-JSON responses (BibTeX, DataCite XML)
- Bearer token auth header injection (skipped when no token)
- Structured APIError parsing from Zenodo error responses
- User-friendly hints for 401/403/404/429 errors
- Graceful handling of non-JSON error bodies
- 204 No Content handling
- 11 tests via httptest mock server

Closes #5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 17, 2026 19:42
@ran-codes ran-codes merged commit 81c1538 into main Feb 17, 2026
5 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a shared Zenodo HTTP API client so CLI subcommands can consistently handle auth, JSON request/response handling, and Zenodo-style error parsing/hints.

Changes:

  • Introduces internal/api.Client with JSON helpers (Get/Post/Put/Delete) plus GetRaw for non-JSON formats.
  • Adds internal/model.APIError (structured errors + user-facing hints).
  • Adds httptest-based unit tests for success paths, auth behavior, query params, and error parsing.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
internal/api/client.go Core HTTP client implementation (request building, auth headers, JSON encode/decode, error parsing, raw GET).
internal/model/error.go API error model with formatted Error() and status-based Hint().
internal/api/client_test.go Unit tests covering HTTP methods, headers, query params, error parsing, and raw responses.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +83 to +88
client := NewClient(srv.URL, "")
q := make(map[string][]string)
q["q"] = []string{"test"}
q["page"] = []string{"2"}
err := client.Get("/search", q, nil)
if err != nil {
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestGet_QueryParams builds q as map[string][]string, but Client.Get expects url.Values (a distinct defined type), so this test (and the package) won’t compile. Define q as url.Values and add the net/url import (or convert with url.Values(q) if you want to keep the map literal).

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +84
// Content headers.
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client currently doesn’t set a User-Agent header. Issue #5’s acceptance criteria requires User-Agent: zenodo-cli/<version> on requests, so this needs to be added (in both do() and GetRaw()).

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
reqURL := c.baseURL + path
if query != nil {
reqURL += "?" + query.Encode()
}

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reqURL := c.baseURL + path / manual ? concatenation can produce malformed URLs (e.g., double slashes, missing slashes, or incorrect escaping) when baseURL has a trailing slash or path lacks a leading slash. Prefer building the URL via url.Parse(c.baseURL) + ResolveReference / JoinPath, and apply the query via u.RawQuery = query.Encode().

Suggested change
reqURL := c.baseURL + path
if query != nil {
reqURL += "?" + query.Encode()
}
base, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("parsing base URL: %w", err)
}
var u *url.URL
if path != "" {
ref, err := url.Parse(path)
if err != nil {
return fmt.Errorf("parsing request path: %w", err)
}
u = base.ResolveReference(ref)
} else {
u = base
}
if query != nil {
u.RawQuery = query.Encode()
}
reqURL := u.String()

Copilot uses AI. Check for mistakes.
case 404:
return "Record not found. Check the ID and ensure you have access."
case 429:
return "Rate limit exceeded. The CLI will automatically retry; if this persists, reduce request frequency."
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hint() for HTTP 429 says the CLI will "automatically retry", but there is no retry logic in the new client (and no other retry implementation in the repo). Either implement retries (respecting Retry-After) or change the hint text to avoid promising behavior that doesn’t exist.

Suggested change
return "Rate limit exceeded. The CLI will automatically retry; if this persists, reduce request frequency."
return "Rate limit exceeded. Wait before retrying, and reduce request frequency if the problem persists."

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +52
// Get performs a GET request and decodes the JSON response into result.
func (c *Client) Get(path string, query url.Values, result interface{}) error {
return c.do(http.MethodGet, path, query, nil, result)
}

// Post performs a POST request with a JSON body and decodes the response.
func (c *Client) Post(path string, body interface{}, result interface{}) error {
return c.do(http.MethodPost, path, nil, body, result)
}

// Put performs a PUT request with a JSON body and decodes the response.
func (c *Client) Put(path string, body interface{}, result interface{}) error {
return c.do(http.MethodPut, path, nil, body, result)
}

// Delete performs a DELETE request.
func (c *Client) Delete(path string, result interface{}) error {
return c.do(http.MethodDelete, path, nil, nil, result)
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public method signatures (Get/Post/Put/Delete returning only error and decoding into a result param) differ from Issue #5’s specified API (methods returning *http.Response, error). Since this PR closes #5, either update the implementation to match the agreed interface or update the issue/PR description so downstream callers know which contract to rely on.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +152
reqURL := c.baseURL + path

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetRaw also constructs URLs via string concatenation (c.baseURL + path), which has the same malformed-URL risk as do() (trailing/leading slashes, escaping). Build the URL with url.Parse + ResolveReference / JoinPath here as well to keep behavior consistent.

Copilot uses AI. Check for mistakes.
@ran-codes ran-codes deleted the issue-5/api-client branch February 18, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement HTTP API client with auth and error handling

2 participants