diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index 0a5c71ddddc04..f241bc052024d 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -22,6 +22,12 @@ paths: # type: boolean # default: false # description: When set to "true", only views (entries with type="view") and their viewGroups are returned, excluding cubes + # - in: query + # name: includeSettings + # required: false + # schema: + # type: boolean + # description: When this parameter is present (presence-only, value is ignored), the response includes a `settings` object with server-wide query limits. Omitted by default to keep the response minimal. description: "" operationId: "metaV1" responses: @@ -381,6 +387,22 @@ components: $ref: "#/components/schemas/V1CubeMeta" compilerId: type: "string" + settings: + $ref: "#/components/schemas/V1MetaSettings" + description: "Only present when the request includes the `includeSettings` query parameter." + V1MetaSettings: + type: "object" + description: "Server-wide settings returned alongside meta when `includeSettings` is set. Useful for clients that need to render UI defaults or validate queries before sending them." + required: + - defaultLimit + - maxLimit + properties: + defaultLimit: + type: "integer" + description: "Default row limit applied to incoming queries when `limit` is not specified. Sourced from `CUBEJS_DB_QUERY_DEFAULT_LIMIT`, clamped to `maxLimit`." + maxLimit: + type: "integer" + description: "Maximum row limit allowed for an incoming query. Sourced from `CUBEJS_DB_QUERY_LIMIT`. Requests with `limit` above this value are rejected." V1LoadResultAnnotation: type: "object" required: diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index 08c462c00d178..51b18b8b7dcf1 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -470,17 +470,20 @@ class ApiGateway { userMiddlewares, userAsyncHandler(async (req, res) => { const onlyViews = req.query.onlyViews === 'true'; + const includeSettings = 'includeSettings' in req.query; if ('extended' in req.query) { await this.metaExtended({ context: req.context, res: this.resToResultFn(res), onlyViews, + includeSettings, }); } else { await this.meta({ context: req.context, res: this.resToResultFn(res), onlyViews, + includeSettings, }); } }) @@ -673,12 +676,32 @@ class ApiGateway { })).filter(cube => cube.config.measures?.length || cube.config.dimensions?.length || cube.config.segments?.length); } - public async meta({ context, res, includeCompilerId, onlyCompilerId, onlyViews }: { + /** + * Build the `settings` object returned by `/v1/meta`. Exposes server-wide + * query limits so clients can render correct defaults and validation + * without having to know about Cube env vars. + * + * These are non-sensitive deployment settings — only operational knobs + * that affect query shape (limits, defaults), never credentials, secrets, + * or anything that would let a caller infer infrastructure details. + * + * `defaultLimit` is clamped to `maxLimit` to mirror the behaviour in + * `query.js#normalizeQuery`, where `dbQueryDefaultLimit` is reduced to + * `dbQueryLimit` if it ever exceeds it. + */ + protected getMetaSettings(): { defaultLimit: number, maxLimit: number } { + const maxLimit = getEnv('dbQueryLimit'); + const defaultLimit = Math.min(getEnv('dbQueryDefaultLimit'), maxLimit); + return { defaultLimit, maxLimit }; + } + + public async meta({ context, res, includeCompilerId, onlyCompilerId, onlyViews, includeSettings }: { context: RequestContext, res: MetaResponseResultFn, includeCompilerId?: boolean, onlyCompilerId?: boolean, onlyViews?: boolean, + includeSettings?: boolean, }) { const requestStarted = new Date(); @@ -709,13 +732,21 @@ class ApiGateway { views: group.views.filter((v: string) => visibleCubeNames.has(v)), })) .filter(group => group.views.length > 0); - const response: { cubes: any[], viewGroups?: any[], compilerId?: string } = { cubes }; + const response: { + cubes: any[], + viewGroups?: any[], + compilerId?: string, + settings?: { defaultLimit: number, maxLimit: number }, + } = { cubes }; if (viewGroups.length > 0) { response.viewGroups = viewGroups; } if (includeCompilerId) { response.compilerId = metaConfig.compilerId; } + if (includeSettings) { + response.settings = this.getMetaSettings(); + } res(response); } catch (e: any) { this.handleError({ @@ -728,10 +759,11 @@ class ApiGateway { } } - public async metaExtended({ context, res, onlyViews }: { + public async metaExtended({ context, res, onlyViews, includeSettings }: { context: ExtendedRequestContext, res: ResponseResultFn, onlyViews?: boolean, + includeSettings?: boolean, }) { const requestStarted = new Date(); @@ -763,7 +795,10 @@ class ApiGateway { preAggregations: transformPreAggregations(cubeDefinitions[cube.name]?.preAggregations), })); - await res({ cubes }); + await res({ + cubes, + ...(includeSettings ? { settings: this.getMetaSettings() } : {}), + }); } catch (e: any) { this.handleError({ e, diff --git a/packages/cubejs-api-gateway/src/types/request.ts b/packages/cubejs-api-gateway/src/types/request.ts index 8478d008354a2..dd4b2db985e54 100644 --- a/packages/cubejs-api-gateway/src/types/request.ts +++ b/packages/cubejs-api-gateway/src/types/request.ts @@ -95,7 +95,13 @@ type ErrorResponse = { error: string, }; -type MetaResponse = { cubes: any[], viewGroups?: any[], compilerId?: string }; +type MetaResponseSettings = { defaultLimit: number, maxLimit: number }; +type MetaResponse = { + cubes: any[], + viewGroups?: any[], + compilerId?: string, + settings?: MetaResponseSettings, +}; type MetaResponseResultFn = (message: MetaResponse | ErrorResponse) => void; /** diff --git a/packages/cubejs-api-gateway/test/index.test.ts b/packages/cubejs-api-gateway/test/index.test.ts index 43e6efed80a7c..1f8901d84352f 100644 --- a/packages/cubejs-api-gateway/test/index.test.ts +++ b/packages/cubejs-api-gateway/test/index.test.ts @@ -686,6 +686,55 @@ describe('API Gateway', () => { expect(res.body.cubes[0]?.segments.find(segment => segment.name === 'Foo.quux').description).toBe('segment from compilerApi mock'); }); + test('meta endpoint omits settings by default', async () => { + const { app } = await createApiGateway(); + + const res = await request(app) + .get('/cubejs-api/v1/meta') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .expect(200); + + expect(res.body).not.toHaveProperty('settings'); + }); + + test('meta endpoint exposes query limit settings when includeSettings is set', async () => { + const { app } = await createApiGateway(); + + const res = await request(app) + .get('/cubejs-api/v1/meta?includeSettings') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .expect(200); + + expect(res.body).toHaveProperty('settings'); + expect(typeof res.body.settings.defaultLimit).toBe('number'); + expect(typeof res.body.settings.maxLimit).toBe('number'); + expect(res.body.settings.defaultLimit).toBeLessThanOrEqual(res.body.settings.maxLimit); + }); + + test('meta endpoint extended omits settings by default', async () => { + const { app } = await createApiGateway(); + + const res = await request(app) + .get('/cubejs-api/v1/meta?extended') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .expect(200); + + expect(res.body).not.toHaveProperty('settings'); + }); + + test('meta endpoint extended exposes query limit settings when includeSettings is set', async () => { + const { app } = await createApiGateway(); + + const res = await request(app) + .get('/cubejs-api/v1/meta?extended&includeSettings') + .set('Authorization', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.t-IDcSemACt8x4iTMCda8Yhe3iZaWbvV5XKSTbuAn0M') + .expect(200); + + expect(res.body).toHaveProperty('settings'); + expect(typeof res.body.settings.defaultLimit).toBe('number'); + expect(typeof res.body.settings.maxLimit).toBe('number'); + }); + test('meta endpoint returns view groups', async () => { const { app } = await createApiGateway(); diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index 876533d97d471..e06f7182d4e9b 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -548,9 +548,15 @@ export type ViewGroup = { views: string[]; }; +export type MetaResponseSettings = { + defaultLimit: number; + maxLimit: number; +}; + export type MetaResponse = { cubes: Cube[]; viewGroups?: ViewGroup[]; + settings?: MetaResponseSettings; }; export type FilterOperator = {