From 2e770437b2eb7fc1fc87da0399826104842e6014 Mon Sep 17 00:00:00 2001 From: Ramakanth A Date: Wed, 3 Jun 2026 13:43:53 -0700 Subject: [PATCH 1/4] docs: add API wrapper design spec --- .../specs/2026-06-03-api-wrapper-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-api-wrapper-design.md diff --git a/docs/superpowers/specs/2026-06-03-api-wrapper-design.md b/docs/superpowers/specs/2026-06-03-api-wrapper-design.md new file mode 100644 index 000000000..f25ced478 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-api-wrapper-design.md @@ -0,0 +1,91 @@ +# Front-end API Wrapper Design + +**Date:** 2026-06-03 +**Issue:** Define a Common Interface for Front-end API Requests + +## Problem + +Axios is imported and called directly in at least 8 places across the front-end (`store/index.js`, `CampaignCards.vue`, `CauseCarousel.vue`, `DonateMoney.vue`, `LetterLoad.vue`, `SearchReps.vue`, `SignName.vue`, `vue_logger.js`). There is no consistent error type, no shared base-URL logic, and no seam to mock in tests. + +## Solution + +Create `src/api/index.js` — a class-based wrapper following the existing `PaymentPresenter` / `PaymentPresenterError` pattern in `shared/presenters/payment-presenter.js`. + +## API Module (`src/api/index.js`) + +### `APIError` + +Extends `Error`. Sets `this.name = 'APIError'` and optionally carries `this.status` (HTTP status code) for callers that need to branch on it. + +```js +class APIError extends Error { + constructor(message, status) { + super(message) + this.name = 'APIError' + this.status = status + } +} +``` + +### `API` class + +Constructor accepts: +- `path` (string, required) — resource path, e.g. `'/campaigns'` +- `version` (string, optional) — API version segment, e.g. `'v1'` + +Builds `baseUrl`: `/api/v1/campaigns` (with version) or `/api/campaigns` (without). + +Exposes four methods mirroring HTTP verbs: +- `get(endpoint = '', params = {})` — appends endpoint to baseUrl, passes params as query string +- `post(endpoint = '', data = {})` — POST to baseUrl + endpoint +- `put(endpoint = '', data = {})` — PUT to baseUrl + endpoint +- `delete(endpoint = '')` — DELETE to baseUrl + endpoint + +All methods: +1. `await` the axios call +2. Return `response.data` on success +3. Catch errors and rethrow as `APIError(error.message, error.response?.status)` + +### Usage examples + +```js +// Versioned resource +const letterTemplates = new API('/letter_templates', 'v1') +const rendered = await letterTemplates.post('/render', { mergeVariables, templateId }) + +// Unversioned resource +const representatives = new API('/representatives') +const reps = await representatives.get(`/${postalCode}`) +``` + +## Tests (`src/api/__tests__/api.test.js`) + +Using Jest with `jest.mock('axios')`. + +| Test | Assertion | +|------|-----------| +| URL construction with version | `baseUrl` equals `/api/v1/path` | +| URL construction without version | `baseUrl` equals `/api/path` | +| Successful GET returns `response.data` | resolved value equals mocked data | +| Successful POST returns `response.data` | resolved value equals mocked data | +| Axios error is rethrown as `APIError` | thrown instance is `APIError` | +| `APIError` carries HTTP status | `error.status` matches mocked response status | + +## Refactoring Scope + +Migrate as examples of the new pattern — not a wholesale migration: + +1. **`src/store/index.js` `loadLetterTemplate` action** — uses `/api/v1/letter_templates/:id` (v1 route) +2. **`src/components/LetterLoad.vue` `renderLetter` method** — uses `/api/v1/letter_templates/render` (v1 route) + +All other existing axios calls (SearchReps, DonateMoney, SignName, CampaignCards, CauseCarousel, vue_logger) are left for a follow-up migration issue. + +## File Structure + +``` +src/ + api/ + index.js ← new + __tests__/ + api.test.js ← new +``` From da1afb2cdc47a7e8a0d00a8965ff6cccb3c2d172 Mon Sep 17 00:00:00 2001 From: Ramakanth A Date: Wed, 3 Jun 2026 13:46:52 -0700 Subject: [PATCH 2/4] docs: add API wrapper implementation plan --- .../plans/2026-06-03-api-wrapper.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-api-wrapper.md diff --git a/docs/superpowers/plans/2026-06-03-api-wrapper.md b/docs/superpowers/plans/2026-06-03-api-wrapper.md new file mode 100644 index 000000000..82c436a29 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-api-wrapper.md @@ -0,0 +1,360 @@ +# Front-end API Wrapper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create `src/api/index.js` — a class-based axios wrapper with a custom `APIError`, following the existing `PaymentPresenter` pattern — and migrate the two v1 routes as working examples. + +**Architecture:** An `API` class takes a resource path and optional version string, builds a base URL, and exposes `get/post/put/delete` methods that return `response.data` on success and throw `APIError` on failure. Two existing call sites (`store/index.js:loadLetterTemplate` and `LetterLoad.vue:renderLetter`) are refactored to use the new class. + +**Tech Stack:** Vue 2, Vuex 3, axios 0.27, Jest 28, Babel (`@vue/cli-plugin-babel/preset`) + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/api/index.js` | Create | `APIError` class + `API` class | +| `src/api/__tests__/api.test.js` | Create | Unit tests (Jest, mocked axios) | +| `src/store/index.js` | Modify (lines 1, 123–139) | Replace axios with `API` in `loadLetterTemplate` | +| `src/components/LetterLoad.vue` | Modify (lines 53, 132–137) | Replace axios with `API` in `renderLetter` | + +--- + +## Task 1: Bootstrap the test file and watch it fail + +**Files:** +- Create: `src/api/__tests__/api.test.js` + +- [ ] **Step 1: Create the test directory and file** + +```bash +mkdir -p src/api/__tests__ +``` + +- [ ] **Step 2: Write the test file** + +Save to `src/api/__tests__/api.test.js`: + +```js +import axios from 'axios' +import { API, APIError } from '../index' + +jest.mock('axios') + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('API — URL construction', () => { + test('builds base URL with version', () => { + const api = new API('/campaigns', 'v1') + expect(api.baseUrl).toBe('/api/v1/campaigns') + }) + + test('builds base URL without version', () => { + const api = new API('/representatives') + expect(api.baseUrl).toBe('/api/representatives') + }) +}) + +describe('API — get()', () => { + test('returns response.data on success', async () => { + const mockData = { id: 1, name: 'test' } + axios.get.mockResolvedValue({ data: mockData }) + + const api = new API('/campaigns') + const result = await api.get() + expect(result).toEqual(mockData) + }) + + test('throws APIError on failure', async () => { + axios.get.mockRejectedValue({ message: 'Not found', response: { status: 404 } }) + + const api = new API('/campaigns') + await expect(api.get()).rejects.toThrow(APIError) + }) + + test('APIError carries HTTP status', async () => { + axios.get.mockRejectedValue({ message: 'Not found', response: { status: 404 } }) + + const api = new API('/campaigns') + await expect(api.get()).rejects.toMatchObject({ status: 404 }) + }) +}) + +describe('API — post()', () => { + test('returns response.data on success', async () => { + const mockData = { letter: '

Hello

' } + axios.post.mockResolvedValue({ data: mockData }) + + const api = new API('/letter_templates', 'v1') + const result = await api.post('/render', { templateId: 1 }) + expect(result).toEqual(mockData) + }) + + test('throws APIError on failure', async () => { + axios.post.mockRejectedValue({ message: 'Server error', response: { status: 500 } }) + + const api = new API('/letter_templates', 'v1') + await expect(api.post('/render', {})).rejects.toThrow(APIError) + }) +}) +``` + +- [ ] **Step 3: Run tests — confirm they fail with "Cannot find module '../index'"** + +```bash +npx jest src/api/__tests__/api.test.js --no-coverage +``` + +Expected output contains: `Cannot find module '../index'` + +--- + +## Task 2: Implement `src/api/index.js` + +**Files:** +- Create: `src/api/index.js` + +- [ ] **Step 1: Create the file** + +Save to `src/api/index.js`: + +```js +import axios from 'axios' + +export class APIError extends Error { + constructor(message, status) { + super(message) + this.name = 'APIError' + this.status = status + } +} + +export class API { + constructor(path, version) { + this.baseUrl = version ? `/api/${version}${path}` : `/api${path}` + } + + async get(endpoint = '', params = {}) { + try { + const res = await axios.get(this.baseUrl + endpoint, { params }) + return res.data + } catch (e) { + throw new APIError(e.message, e.response?.status) + } + } + + async post(endpoint = '', data = {}) { + try { + const res = await axios.post(this.baseUrl + endpoint, data) + return res.data + } catch (e) { + throw new APIError(e.message, e.response?.status) + } + } + + async put(endpoint = '', data = {}) { + try { + const res = await axios.put(this.baseUrl + endpoint, data) + return res.data + } catch (e) { + throw new APIError(e.message, e.response?.status) + } + } + + async delete(endpoint = '') { + try { + const res = await axios.delete(this.baseUrl + endpoint) + return res.data + } catch (e) { + throw new APIError(e.message, e.response?.status) + } + } +} +``` + +- [ ] **Step 2: Run tests — confirm all pass** + +```bash +npx jest src/api/__tests__/api.test.js --no-coverage +``` + +Expected output: `Tests: 7 passed, 7 total` + +- [ ] **Step 3: Commit** + +```bash +git add src/api/index.js src/api/__tests__/api.test.js +git commit -m "feat: add API wrapper class and APIError" +``` + +--- + +## Task 3: Refactor `loadLetterTemplate` in `src/store/index.js` + +**Files:** +- Modify: `src/store/index.js` + +The current action (lines 123–139) calls `axios.get` directly and uses the full URL string: + +```js +// CURRENT — remove this +import axios from 'axios' +... +async loadLetterTemplate({ state, commit }) { + const templateUrl = `/api/v1/letter_templates/${state.campaign.letterTemplateId}` + try { + const res = await axios.get(templateUrl) + const { letterTemplate } = res.data + console.log(letterTemplate) + commit('setGenericValue', { key: 'letterTemplate', value: letterTemplate }) + } catch (e) { + alert(e) + } +} +``` + +- [ ] **Step 1: Add the API import at line 1 of `src/store/index.js`** + +Replace: +```js +import axios from 'axios' +``` +With: +```js +import { API } from '@/api' +``` + +- [ ] **Step 2: Replace `loadLetterTemplate` action (lines 123–139)** + +Replace: +```js + async loadLetterTemplate({ state, commit }) { + const templateUrl = `/api/v1/letter_templates/${state.campaign.letterTemplateId}` + + try { + const res = await axios.get(templateUrl) + + const { letterTemplate } = res.data + console.log(letterTemplate) + + commit('setGenericValue', { + key: 'letterTemplate', + value: letterTemplate + }) + } catch (e) { + alert(e) + } + } +``` + +With: +```js + async loadLetterTemplate({ state, commit }) { + const letterTemplatesApi = new API('/letter_templates', 'v1') + + try { + const data = await letterTemplatesApi.get(`/${state.campaign.letterTemplateId}`) + + commit('setGenericValue', { + key: 'letterTemplate', + value: data.letterTemplate + }) + } catch (e) { + alert(e.message) + } + } +``` + +- [ ] **Step 3: Run the full test suite to confirm nothing broke** + +```bash +npx jest --no-coverage +``` + +Expected: all previously passing tests still pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/store/index.js +git commit -m "refactor: use API wrapper in loadLetterTemplate store action" +``` + +--- + +## Task 4: Refactor `renderLetter` in `src/components/LetterLoad.vue` + +**Files:** +- Modify: `src/components/LetterLoad.vue` + +The current method (lines 53, 132–137) imports axios directly: + +```js +// CURRENT — to be replaced +import axios from 'axios' +... +renderLetter() { + axios.post('/api/v1/letter_templates/render', { mergeVariables: { ...this.userSelections, representativeName: this.selectedRep.name, firstName: '', lastName: '' }, templateId: this.letterTemplate.id }) + .then((res) => { + this.letterBody = res.data.letter + }) +} +``` + +- [ ] **Step 1: Replace the `axios` import in the `