-
Notifications
You must be signed in to change notification settings - Fork 0
Add Excel (.xlsx) export for API datasets #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
cedd01b
Add Excel (.xlsx) export endpoint for API datasets
Copilot 3843922
Remove XML-forbidden XLSX noncharacters
Copilot 61b0632
Clarify XLSX XML sanitizer comment
Copilot 6b0a808
Validate export params and bound row counts
Copilot 17545b2
Preserve query parsing for all exports
Copilot 1aa296a
Simplify export param validation config
Copilot 28db5e6
Refine export validation and row bounds
Copilot 89fcf49
Remove redundant export validation checks
Copilot 24a8deb
Restore missing taxYear validation message
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| /** | ||
| * Exportable datasets. | ||
| * | ||
| * Every dataset describes the sheets of a workbook plus a loader that pulls the | ||
| * rows from the same store/finance service the GraphQL resolvers use, so an | ||
| * export always matches what the API returns. | ||
| */ | ||
| import { isGraphQLInt, validateFinanceArgs } from '../validation/financeArgs.js'; | ||
|
|
||
| /** Parses the shared pagination/filter query parameters of an export request. */ | ||
| function readParams(params = {}, { datasetName = 'export', validate = false, requireTaxYear = false } = {}) { | ||
| const issues = []; | ||
| const addIssue = (message) => { | ||
| if (validate) issues.push(message); | ||
| }; | ||
| const optionalInt = (name) => { | ||
| if (!Object.hasOwn(params, name) || params[name] === undefined || params[name] === null) return undefined; | ||
| const raw = params[name]; | ||
| if (Array.isArray(raw) || raw === '') { | ||
| addIssue(`${name} must be an integer`); | ||
| return undefined; | ||
| } | ||
| const value = String(raw); | ||
| if (!/^-?\d+$/.test(value)) { | ||
| addIssue(`${name} must be an integer`); | ||
| return undefined; | ||
| } | ||
| const parsed = Number(value); | ||
| if (!isGraphQLInt(parsed)) { | ||
| addIssue(`${name} must be a 32-bit integer`); | ||
| return undefined; | ||
| } | ||
| return parsed; | ||
| }; | ||
| const optionalString = (name) => { | ||
| if (!Object.hasOwn(params, name) || params[name] === undefined || params[name] === null) return undefined; | ||
| if (Array.isArray(params[name])) { | ||
| addIssue(`${name} must be a string`); | ||
| return undefined; | ||
| } | ||
| return String(params[name]); | ||
| }; | ||
|
|
||
| const parsed = { | ||
| accountId: optionalString('accountId'), | ||
| symbol: optionalString('symbol'), | ||
| side: optionalString('side'), | ||
| status: optionalString('status'), | ||
| from: optionalString('from'), | ||
| to: optionalString('to'), | ||
| limit: optionalInt('limit'), | ||
| offset: optionalInt('offset'), | ||
| taxYear: optionalInt('taxYear'), | ||
| }; | ||
|
|
||
| if (validate) issues.push(...validateFinanceArgs(parsed, { requireTaxYear })); | ||
| if (issues.length > 0) { | ||
| throw Object.assign(new Error(`invalid query parameters for ${datasetName} export: ${issues.join('; ')}`), { statusCode: 400 }); | ||
| } | ||
|
|
||
| return parsed; | ||
| } | ||
|
|
||
| function countRows(sheets) { | ||
| return sheets.reduce((total, sheet) => total + (sheet.rows?.length ?? 0), 0); | ||
| } | ||
|
|
||
| function enforceRowLimit(rowCount, maxRows) { | ||
| if (maxRows !== undefined && rowCount > maxRows) { | ||
| throw Object.assign(new Error(`export contains ${rowCount} rows, which exceeds the limit of ${maxRows}`), { statusCode: 413 }); | ||
| } | ||
| } | ||
|
|
||
| const USER_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'name', header: 'Name' }, | ||
| { key: 'email', header: 'Email' }, | ||
| { key: 'postCount', header: 'Posts' }, | ||
| ]; | ||
|
|
||
| const POST_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'title', header: 'Title' }, | ||
| { key: 'content', header: 'Content' }, | ||
| { key: 'authorId', header: 'Author Id' }, | ||
| { key: 'authorName', header: 'Author' }, | ||
| ]; | ||
|
|
||
| const ACCOUNT_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'name', header: 'Name' }, | ||
| { key: 'type', header: 'Type' }, | ||
| { key: 'currency', header: 'Currency' }, | ||
| { key: 'provider', header: 'Provider' }, | ||
| ]; | ||
|
|
||
| const POSITION_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'accountId', header: 'Account Id' }, | ||
| { key: 'symbol', header: 'Symbol' }, | ||
| { key: 'quantity', header: 'Quantity' }, | ||
| { key: 'averageCost', header: 'Average Cost' }, | ||
| { key: 'marketPrice', header: 'Market Price' }, | ||
| { key: 'marketValue', header: 'Market Value' }, | ||
| { key: 'unrealizedPnL', header: 'Unrealized P/L' }, | ||
| ]; | ||
|
|
||
| const PERFORMANCE_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'accountId', header: 'Account Id' }, | ||
| { key: 'asOf', header: 'As Of' }, | ||
| { key: 'totalValue', header: 'Total Value' }, | ||
| { key: 'cash', header: 'Cash' }, | ||
| { key: 'marketValue', header: 'Market Value' }, | ||
| { key: 'dayPnL', header: 'Day P/L' }, | ||
| { key: 'totalPnL', header: 'Total P/L' }, | ||
| ]; | ||
|
|
||
| const TRADE_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'accountId', header: 'Account Id' }, | ||
| { key: 'orderId', header: 'Order Id' }, | ||
| { key: 'symbol', header: 'Symbol' }, | ||
| { key: 'side', header: 'Side' }, | ||
| { key: 'quantity', header: 'Quantity' }, | ||
| { key: 'price', header: 'Price' }, | ||
| { key: 'status', header: 'Status' }, | ||
| { key: 'executedAt', header: 'Executed At' }, | ||
| ]; | ||
|
|
||
| const ORDER_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'accountId', header: 'Account Id' }, | ||
| { key: 'symbol', header: 'Symbol' }, | ||
| { key: 'side', header: 'Side' }, | ||
| { key: 'quantity', header: 'Quantity' }, | ||
| { key: 'limitPrice', header: 'Limit Price' }, | ||
| { key: 'status', header: 'Status' }, | ||
| { key: 'createdAt', header: 'Created At' }, | ||
| ]; | ||
|
|
||
| const TAX_EVENT_COLUMNS = [ | ||
| { key: 'id', header: 'Id' }, | ||
| { key: 'tradeId', header: 'Trade Id' }, | ||
| { key: 'symbol', header: 'Symbol' }, | ||
| { key: 'quantity', header: 'Quantity' }, | ||
| { key: 'proceeds', header: 'Proceeds' }, | ||
| { key: 'costBasis', header: 'Cost Basis' }, | ||
| { key: 'realizedGain', header: 'Realized Gain' }, | ||
| { key: 'holdingPeriod', header: 'Holding Period' }, | ||
| { key: 'occurredAt', header: 'Occurred At' }, | ||
| ]; | ||
|
|
||
| /** Registry of everything the /export endpoint can produce. */ | ||
| export const datasets = { | ||
| users: { | ||
| filename: 'users', | ||
| async load(_params, { store }) { | ||
| const users = store.listUsers(); | ||
| return { | ||
| sheets: [ | ||
| { | ||
| name: 'Users', | ||
| columns: USER_COLUMNS, | ||
| rows: users.map((user) => ({ ...user, postCount: store.listPostsByAuthor(user.id).length })), | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| }, | ||
|
|
||
| posts: { | ||
| filename: 'posts', | ||
| async load(_params, { store }) { | ||
| const posts = store.listPosts(); | ||
| return { | ||
| sheets: [ | ||
| { | ||
| name: 'Posts', | ||
| columns: POST_COLUMNS, | ||
| rows: posts.map((post) => ({ | ||
| ...post, | ||
| authorName: store.getUser(post.authorId)?.name ?? '', | ||
| })), | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| }, | ||
|
|
||
| portfolio: { | ||
| filename: 'portfolio-overview', | ||
| financeArgs: { validate: true }, | ||
| async load(params, { finance }) { | ||
| const overview = await finance.portfolioOverview(params); | ||
| return { | ||
| sheets: [ | ||
| { name: 'Accounts', columns: ACCOUNT_COLUMNS, rows: overview.accounts }, | ||
| { name: 'Positions', columns: POSITION_COLUMNS, rows: overview.positions }, | ||
| { name: 'Performance', columns: PERFORMANCE_COLUMNS, rows: overview.performance }, | ||
| ], | ||
| errors: overview.errors, | ||
| }; | ||
| }, | ||
| }, | ||
|
|
||
| trades: { | ||
| filename: 'trade-history', | ||
| financeArgs: { validate: true }, | ||
| async load(params, { finance }) { | ||
| const history = await finance.tradeHistory(params); | ||
| return { | ||
| sheets: [ | ||
| { name: 'Trades', columns: TRADE_COLUMNS, rows: history.trades }, | ||
| { name: 'Orders', columns: ORDER_COLUMNS, rows: history.orders }, | ||
| { name: 'Tax Events', columns: TAX_EVENT_COLUMNS, rows: history.taxEvents }, | ||
| ], | ||
| errors: history.errors, | ||
| }; | ||
| }, | ||
| }, | ||
|
|
||
| 'tax-estimate': { | ||
| filename: 'tax-estimate', | ||
| financeArgs: { validate: true, requireTaxYear: true }, | ||
| async load(params, { finance }) { | ||
| const summary = await finance.taxEstimate(params); | ||
| return { | ||
| sheets: [ | ||
| { | ||
| name: 'Summary', | ||
| columns: [ | ||
| { key: 'taxYear', header: 'Tax Year' }, | ||
| { key: 'currency', header: 'Currency' }, | ||
| { key: 'totalProceeds', header: 'Total Proceeds' }, | ||
| { key: 'totalCostBasis', header: 'Total Cost Basis' }, | ||
| { key: 'realizedGain', header: 'Realized Gain' }, | ||
| { key: 'estimatedTax', header: 'Estimated Tax' }, | ||
| { key: 'taxRate', header: 'Tax Rate' }, | ||
| ], | ||
| rows: [summary], | ||
| }, | ||
| { name: 'Tax Events', columns: TAX_EVENT_COLUMNS, rows: summary.events }, | ||
| ], | ||
| errors: summary.errors, | ||
| }; | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| /** Dataset names accepted by the export endpoint. */ | ||
| export const datasetNames = Object.keys(datasets); | ||
|
|
||
| /** | ||
| * Loads a dataset into workbook sheets. | ||
| * | ||
| * @throws {Error & {statusCode: number}} when the dataset is unknown or params are invalid | ||
| */ | ||
| export async function loadDataset(name, params, context) { | ||
| const dataset = datasets[name]; | ||
| if (!dataset) { | ||
| throw Object.assign(new Error(`unknown dataset "${name}"`), { statusCode: 404, datasets: datasetNames }); | ||
| } | ||
|
|
||
| const parsedParams = readParams(params, { datasetName: name, ...dataset.financeArgs }); | ||
| const result = await dataset.load(parsedParams, context); | ||
| enforceRowLimit(countRows(result.sheets), context.maxExportRows); | ||
| return { filename: dataset.filename, ...result }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import express from 'express'; | ||
|
|
||
| import { loadDataset, datasetNames } from './datasets.js'; | ||
| import { buildWorkbook, XLSX_CONTENT_TYPE } from './xlsx.js'; | ||
|
|
||
| const ERROR_COLUMNS = [ | ||
| { key: 'source', header: 'Source' }, | ||
| { key: 'code', header: 'Code' }, | ||
| { key: 'category', header: 'Category' }, | ||
| { key: 'status', header: 'Status' }, | ||
| { key: 'retryable', header: 'Retryable' }, | ||
| { key: 'message', header: 'Message' }, | ||
| ]; | ||
| const DEFAULT_MAX_EXPORT_ROWS = 10000; | ||
|
|
||
| /** | ||
| * Express router exposing spreadsheet downloads. | ||
| * | ||
| * `GET /export` lists the datasets, `GET /export/:dataset(.xlsx)` streams the | ||
| * workbook. Finance datasets accept the same filter/pagination query | ||
| * parameters as their GraphQL counterparts. | ||
| */ | ||
| export function createExportRouter({ store, finance, logger, maxExportRows = DEFAULT_MAX_EXPORT_ROWS } = {}) { | ||
| const router = express.Router(); | ||
|
|
||
| router.get('/', (_req, res) => { | ||
| res.json({ datasets: datasetNames, format: 'xlsx' }); | ||
| }); | ||
|
|
||
| router.get('/:dataset', async (req, res) => { | ||
| // Accept both /export/users and /export/users.xlsx. | ||
| const name = req.params.dataset.replace(/\.xlsx$/i, ''); | ||
|
|
||
| try { | ||
| const { filename, sheets, errors = [] } = await loadDataset(name, req.query, { store, finance, maxExportRows }); | ||
| // Partial finance results carry upstream errors; surface them in the | ||
| // workbook instead of silently shipping incomplete data. | ||
| const allSheets = errors.length > 0 ? [...sheets, { name: 'Errors', columns: ERROR_COLUMNS, rows: errors }] : sheets; | ||
| const workbook = buildWorkbook({ sheets: allSheets }); | ||
|
|
||
| res.set('content-type', XLSX_CONTENT_TYPE); | ||
| res.set('content-disposition', `attachment; filename="${filename}.xlsx"`); | ||
| res.set('content-length', String(workbook.length)); | ||
| res.send(workbook); | ||
| } catch (error) { | ||
| const statusCode = error?.statusCode ?? 500; | ||
| if (statusCode >= 500) { | ||
| logger?.error('export failed', { dataset: name, error: error?.message ?? String(error) }); | ||
| } | ||
| res.status(statusCode).json({ | ||
| error: statusCode >= 500 ? 'export failed' : error.message, | ||
| ...(error?.datasets ? { datasets: error.datasets } : {}), | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| return router; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.