chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
GoogleAppsheetAddParams,
|
||||
GoogleAppsheetAddResponse,
|
||||
} from '@/tools/google_appsheet/types'
|
||||
import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAppsheetAddRowsTool: ToolConfig<
|
||||
GoogleAppsheetAddParams,
|
||||
GoogleAppsheetAddResponse
|
||||
> = {
|
||||
id: 'google_appsheet_add_rows',
|
||||
name: 'AppSheet Add Rows',
|
||||
description:
|
||||
'Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()).',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet Application Access Key',
|
||||
},
|
||||
appId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
|
||||
},
|
||||
tableName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the table to add rows to',
|
||||
},
|
||||
region: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of row objects to add, each a column-name/value map, e.g. [{ "FirstName": "Jan", "LastName": "Jones" }]',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
ApplicationAccessKey: params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
Action: 'Add',
|
||||
Properties: {},
|
||||
Rows: params.rows,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await readAppsheetResponseBody(response)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to add AppSheet rows')
|
||||
}
|
||||
|
||||
const rows = data.Rows ?? data.rows ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
rows,
|
||||
metadata: {
|
||||
rowCount: rows.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: 'Rows added by AppSheet, including any generated key values',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Operation metadata',
|
||||
properties: {
|
||||
rowCount: { type: 'number', description: 'Number of rows added' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
GoogleAppsheetDeleteParams,
|
||||
GoogleAppsheetDeleteResponse,
|
||||
} from '@/tools/google_appsheet/types'
|
||||
import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAppsheetDeleteRowsTool: ToolConfig<
|
||||
GoogleAppsheetDeleteParams,
|
||||
GoogleAppsheetDeleteResponse
|
||||
> = {
|
||||
id: 'google_appsheet_delete_rows',
|
||||
name: 'AppSheet Delete Rows',
|
||||
description:
|
||||
'Delete rows from an AppSheet table. Each row only needs to include the key column name and value.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet Application Access Key',
|
||||
},
|
||||
appId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
|
||||
},
|
||||
tableName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the table to delete rows from',
|
||||
},
|
||||
region: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of row objects identifying rows to delete by key column, e.g. [{ "RowID": "123" }]',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
ApplicationAccessKey: params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
Action: 'Delete',
|
||||
Properties: {},
|
||||
Rows: params.rows,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await readAppsheetResponseBody(response)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to delete AppSheet rows')
|
||||
}
|
||||
|
||||
const rows = data.Rows ?? data.rows ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
rows,
|
||||
metadata: {
|
||||
rowCount: rows.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: 'Rows deleted by AppSheet',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Operation metadata',
|
||||
properties: {
|
||||
rowCount: { type: 'number', description: 'Number of rows deleted' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
GoogleAppsheetEditParams,
|
||||
GoogleAppsheetEditResponse,
|
||||
} from '@/tools/google_appsheet/types'
|
||||
import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAppsheetEditRowsTool: ToolConfig<
|
||||
GoogleAppsheetEditParams,
|
||||
GoogleAppsheetEditResponse
|
||||
> = {
|
||||
id: 'google_appsheet_edit_rows',
|
||||
name: 'AppSheet Edit Rows',
|
||||
description:
|
||||
'Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet Application Access Key',
|
||||
},
|
||||
appId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
|
||||
},
|
||||
tableName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the table to update rows in',
|
||||
},
|
||||
region: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
|
||||
},
|
||||
rows: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of row objects to update, each including the key column and the columns to change, e.g. [{ "RowID": "123", "Status": "Done" }]',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
ApplicationAccessKey: params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
Action: 'Edit',
|
||||
Properties: {},
|
||||
Rows: params.rows,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await readAppsheetResponseBody(response)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to edit AppSheet rows')
|
||||
}
|
||||
|
||||
const rows = data.Rows ?? data.rows ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
rows,
|
||||
metadata: {
|
||||
rowCount: rows.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: 'Rows updated by AppSheet',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Operation metadata',
|
||||
properties: {
|
||||
rowCount: { type: 'number', description: 'Number of rows updated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
GoogleAppsheetFindParams,
|
||||
GoogleAppsheetFindResponse,
|
||||
} from '@/tools/google_appsheet/types'
|
||||
import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAppsheetFindRowsTool: ToolConfig<
|
||||
GoogleAppsheetFindParams,
|
||||
GoogleAppsheetFindResponse
|
||||
> = {
|
||||
id: 'google_appsheet_find_rows',
|
||||
name: 'AppSheet Find Rows',
|
||||
description:
|
||||
'Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet Application Access Key',
|
||||
},
|
||||
appId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
|
||||
},
|
||||
tableName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the table to read from',
|
||||
},
|
||||
region: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Optional AppSheet expression to filter/sort/limit rows, e.g. Filter(TableName, [Age] >= 21) or Top(OrderBy(Filter(TableName, true), [LastName], true), 10)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
ApplicationAccessKey: params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
Action: 'Find',
|
||||
Properties: params.selector ? { Selector: params.selector } : {},
|
||||
Rows: [],
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await readAppsheetResponseBody(response)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to find AppSheet rows')
|
||||
}
|
||||
|
||||
const rows = data.Rows ?? data.rows ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
rows,
|
||||
metadata: {
|
||||
rowCount: rows.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: 'Matching rows returned by AppSheet',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Operation metadata',
|
||||
properties: {
|
||||
rowCount: { type: 'number', description: 'Number of rows returned' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { googleAppsheetAddRowsTool } from '@/tools/google_appsheet/add_rows'
|
||||
import { googleAppsheetDeleteRowsTool } from '@/tools/google_appsheet/delete_rows'
|
||||
import { googleAppsheetEditRowsTool } from '@/tools/google_appsheet/edit_rows'
|
||||
import { googleAppsheetFindRowsTool } from '@/tools/google_appsheet/find_rows'
|
||||
|
||||
export {
|
||||
googleAppsheetAddRowsTool,
|
||||
googleAppsheetDeleteRowsTool,
|
||||
googleAppsheetEditRowsTool,
|
||||
googleAppsheetFindRowsTool,
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
interface GoogleAppsheetBaseParams {
|
||||
apiKey: string
|
||||
appId: string
|
||||
tableName: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
export type GoogleAppsheetRow = Record<string, unknown>
|
||||
|
||||
// Find Rows Types
|
||||
export interface GoogleAppsheetFindParams extends GoogleAppsheetBaseParams {
|
||||
selector?: string
|
||||
}
|
||||
|
||||
export interface GoogleAppsheetFindResponse extends ToolResponse {
|
||||
output: {
|
||||
rows: GoogleAppsheetRow[]
|
||||
metadata: {
|
||||
rowCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Rows Types
|
||||
export interface GoogleAppsheetAddParams extends GoogleAppsheetBaseParams {
|
||||
rows: GoogleAppsheetRow[]
|
||||
}
|
||||
|
||||
export interface GoogleAppsheetAddResponse extends ToolResponse {
|
||||
output: {
|
||||
rows: GoogleAppsheetRow[]
|
||||
metadata: {
|
||||
rowCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Rows Types
|
||||
export interface GoogleAppsheetEditParams extends GoogleAppsheetBaseParams {
|
||||
rows: GoogleAppsheetRow[]
|
||||
}
|
||||
|
||||
export interface GoogleAppsheetEditResponse extends ToolResponse {
|
||||
output: {
|
||||
rows: GoogleAppsheetRow[]
|
||||
metadata: {
|
||||
rowCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Rows Types
|
||||
export interface GoogleAppsheetDeleteParams extends GoogleAppsheetBaseParams {
|
||||
rows: GoogleAppsheetRow[]
|
||||
}
|
||||
|
||||
export interface GoogleAppsheetDeleteResponse extends ToolResponse {
|
||||
output: {
|
||||
rows: GoogleAppsheetRow[]
|
||||
metadata: {
|
||||
rowCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GoogleAppsheetResponse =
|
||||
| GoogleAppsheetFindResponse
|
||||
| GoogleAppsheetAddResponse
|
||||
| GoogleAppsheetEditResponse
|
||||
| GoogleAppsheetDeleteResponse
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
|
||||
|
||||
describe('buildAppsheetActionUrl', () => {
|
||||
it('defaults to the global www region when unset', () => {
|
||||
expect(buildAppsheetActionUrl('app-1', 'Orders')).toBe(
|
||||
'https://www.appsheet.com/api/v2/apps/app-1/tables/Orders/Action'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds the URL for a valid non-default region', () => {
|
||||
expect(buildAppsheetActionUrl('app-1', 'Orders', 'eu')).toBe(
|
||||
'https://eu.appsheet.com/api/v2/apps/app-1/tables/Orders/Action'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims and URL-encodes appId and tableName', () => {
|
||||
expect(buildAppsheetActionUrl(' app 1 ', ' My Table ')).toBe(
|
||||
'https://www.appsheet.com/api/v2/apps/app%201/tables/My%20Table/Action'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a region outside the known AppSheet regions', () => {
|
||||
expect(() => buildAppsheetActionUrl('app-1', 'Orders', 'attacker.example.com')).toThrow(
|
||||
/Invalid AppSheet region/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAppsheetResponseBody', () => {
|
||||
it('parses a JSON body', async () => {
|
||||
const response = new Response('{"Rows":[{"id":"1"}]}')
|
||||
expect(await readAppsheetResponseBody(response)).toEqual({ Rows: [{ id: '1' }] })
|
||||
})
|
||||
|
||||
it('returns an empty object for an empty body', async () => {
|
||||
const response = new Response('')
|
||||
expect(await readAppsheetResponseBody(response)).toEqual({})
|
||||
})
|
||||
|
||||
it('wraps a non-JSON body as a message instead of throwing', async () => {
|
||||
const response = new Response('<html>502 Bad Gateway</html>')
|
||||
expect(await readAppsheetResponseBody(response)).toEqual({
|
||||
message: '<html>502 Bad Gateway</html>',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
const DEFAULT_REGION = 'www'
|
||||
const ALLOWED_REGIONS = new Set(['www', 'eu', 'asia-southeast'])
|
||||
|
||||
/**
|
||||
* Builds the AppSheet API Action endpoint URL for a given app/table/region.
|
||||
* Region defaults to the global `www.appsheet.com` domain when unset, and is
|
||||
* validated against the known AppSheet regions since it is interpolated into
|
||||
* the request host — an unvalidated value would let a caller redirect the
|
||||
* Application Access Key to an arbitrary host.
|
||||
*/
|
||||
export function buildAppsheetActionUrl(appId: string, tableName: string, region?: string): string {
|
||||
const trimmedRegion = (region || DEFAULT_REGION).trim()
|
||||
if (!ALLOWED_REGIONS.has(trimmedRegion)) {
|
||||
throw new Error(
|
||||
`Invalid AppSheet region "${trimmedRegion}". Must be one of: ${Array.from(ALLOWED_REGIONS).join(', ')}.`
|
||||
)
|
||||
}
|
||||
const host = `${trimmedRegion}.appsheet.com`
|
||||
return `https://${host}/api/v2/apps/${encodeURIComponent(appId.trim())}/tables/${encodeURIComponent(tableName.trim())}/Action`
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely reads an AppSheet API response body. AppSheet does not consistently
|
||||
* document whether every Action returns a JSON body (e.g. Delete may return an
|
||||
* empty body on some accounts), so this avoids `response.json()` throwing on
|
||||
* empty or non-JSON content.
|
||||
*/
|
||||
export async function readAppsheetResponseBody(response: Response): Promise<Record<string, any>> {
|
||||
const text = await response.text()
|
||||
if (!text) return {}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { message: text }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user