queryapigate 0.47.0__tar.gz → 0.48.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queryapigate-0.47.0/queryapigate.egg-info → queryapigate-0.48.0}/PKG-INFO +1 -1
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/alerts.py +4 -4
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/app.py +20 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cli.py +3 -3
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/dataplane.py +33 -3
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/governance.py +15 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/limits.py +3 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mcp_server.py +3 -1
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/metrics.py +2 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/ratelimit.py +9 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sqltools.py +6 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0/queryapigate.egg-info}/PKG-INFO +1 -1
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_cli.py +13 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_dataplane.py +129 -4
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_integration.py +11 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp.py +13 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_queryapigate.py +11 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_quotas.py +12 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_runstore.py +8 -3
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_shared_rate_limit.py +13 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_supply_chain.py +38 -2
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_isolation.py +24 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/LICENSE +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/NOTICE +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/README.md +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/pyproject.toml +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/accesslog.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/adminroles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/admins.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/apikeys.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/auditstream.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/aws.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/bundle.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/config.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/configcode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/index.html +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/databases.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/db.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/definitions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/embeddings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/engine.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/events.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/experimental.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/exporting.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/formats.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/gunicorn_conf.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/history.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/instances.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/masking.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/metacache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mongodocs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/oidc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/openapi.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/params.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/parquet.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/quotas.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/rekey.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/runners.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/runstore.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/schema.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/scope.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/secretrefs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/access.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/workspaces.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sso.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/store.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/tableguard.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/tracing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/usage.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/v1.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/v1_spec.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/vault.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/workspaces.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/SOURCES.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/setup.cfg +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_adminroles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_admins.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_alerts.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_v1.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_log.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_siem.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_aws.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_broadcast.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collections.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_configcode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_console.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_data_protection.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_databases.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_db.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_dependency_floors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_deprecations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_descriptions.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_destinations.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_docs_site.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_embeddings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_errors.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_events.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_events_server.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_examples.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_experimental.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_export_security.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_exporting.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_exports.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_fips.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_helm.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_history.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_howto.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_instances.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_jwt.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_limits.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_masking.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_metacache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mongo_streaming.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mongotools.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_multinode.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_observability.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_oidc.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_params.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_parquet.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_pool.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_publishing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_query_window.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_rekey.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_roles.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_runstore_differential.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_secretrefs.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_security_headers.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_settings.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sql_guard_fuzz.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sql_guard_writes.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sso.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_tableguard.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_tracing.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_upgrades.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_usage.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_vault.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_config_cli.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_observability.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_store.py +0 -0
- {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspaces_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.48.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -44,13 +44,13 @@ _lock = threading.Lock()
|
|
|
44
44
|
_refusals: dict[tuple, deque] = {} # ('key' | 'client', who) -> monotonic times of rate-limit refusals
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def note_rate_limited(kind, who):
|
|
48
|
-
"""
|
|
49
|
-
'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
47
|
+
def note_rate_limited(kind, who, count=1):
|
|
48
|
+
"""A rate-limit refusal (`count` of them, now), for the 'keeps hitting its limit' alert: kind 'key' (a key's own
|
|
49
|
+
rate_limit) or 'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
|
|
50
50
|
now = time.monotonic()
|
|
51
51
|
with _lock:
|
|
52
52
|
times = _refusals.setdefault((kind, who or 'unknown'), deque(maxlen=1000))
|
|
53
|
-
times.
|
|
53
|
+
times.extend([now] * min(count, 1000))
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
# Every kind of alert, the one list: the OpenAPI enum is built from it and tests/test_alerts.py checks API.md's table.
|
|
@@ -178,7 +178,8 @@ class _CapturePeer:
|
|
|
178
178
|
secret names the client's own socket address in X-QAG-Peer, and that address is taken as the real one - so this
|
|
179
179
|
server sees the request as if the client had connected directly, ProxyFix and QUERYAPIGATE_TRUST_PROXY included.
|
|
180
180
|
A peer that isn't an IP address counts as remote, as a Unix socket does. A data plane from another release
|
|
181
|
-
(X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()).
|
|
181
|
+
(X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()). The rate-limit buckets
|
|
182
|
+
it counted a call in before forwarding it (X-QAG-Rate-Counted, ADR 0032) are taken from it alone. Its headers are
|
|
182
183
|
removed here, so nothing after this layer reads, logs or passes on the secret."""
|
|
183
184
|
|
|
184
185
|
def __init__(self, app):
|
|
@@ -189,10 +190,13 @@ class _CapturePeer:
|
|
|
189
190
|
peer = environ.pop(dataplane.PEER_ENVIRON, None)
|
|
190
191
|
wanted = environ.pop(dataplane.WANTED_ENVIRON, None)
|
|
191
192
|
version = environ.pop(dataplane.VERSION_ENVIRON, None)
|
|
193
|
+
rate_counted = environ.pop(dataplane.COUNTED_ENVIRON, None)
|
|
192
194
|
if supplied is not None and dataplane.matches(supplied):
|
|
193
195
|
is_paired = dataplane.paired(version)
|
|
194
196
|
environ['queryapigate.dataplane'] = 'plan' if wanted == '1' and is_paired else True
|
|
195
197
|
environ['queryapigate.dataplane_paired'] = is_paired
|
|
198
|
+
if rate_counted is not None and is_paired:
|
|
199
|
+
environ['queryapigate.rate_counted'] = dataplane.counted(rate_counted)
|
|
196
200
|
environ['REMOTE_ADDR'] = peer if dataplane.is_address(peer) else ''
|
|
197
201
|
environ['queryapigate.peer_addr'] = environ.get('REMOTE_ADDR')
|
|
198
202
|
return self.app(environ, start_response)
|
|
@@ -475,6 +479,10 @@ def check_rate_limit():
|
|
|
475
479
|
"""Count this request against its client's quota (governance.py); returns a 429 response when it is over."""
|
|
476
480
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
477
481
|
return None
|
|
482
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('client')
|
|
483
|
+
if counted is not None and config.rate_limit() is not None: # by the data plane, which then forwarded the call
|
|
484
|
+
g.rate_limit = counted
|
|
485
|
+
return None
|
|
478
486
|
verdict = governance.check_client_limit(current_app, request.remote_addr)
|
|
479
487
|
if verdict is None:
|
|
480
488
|
return None
|
|
@@ -488,6 +496,10 @@ def check_key_rate_limit():
|
|
|
488
496
|
per-key identity to limit by before then; a no-op for the admin key and the open/no-key case."""
|
|
489
497
|
if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
490
498
|
return None
|
|
499
|
+
counted = request.environ.get('queryapigate.rate_counted', {}).get('key')
|
|
500
|
+
if counted is not None and getattr(g.get('permission'), 'rate_limit', None) is not None: # as the client's, above
|
|
501
|
+
g.key_rate_limit = counted
|
|
502
|
+
return None
|
|
491
503
|
verdict = governance.check_key_limit(current_app, g.get('permission'))
|
|
492
504
|
if verdict is None:
|
|
493
505
|
return None
|
|
@@ -1236,9 +1248,15 @@ def dataplane_runs():
|
|
|
1236
1248
|
|
|
1237
1249
|
Each run comes with its history entry already JSON (`entry`), and the labels and timings /metrics counts it by: at
|
|
1238
1250
|
the data plane's rates Python's own work per run - encoding it, and its labels - is what it can't keep up with, so
|
|
1239
|
-
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1251
|
+
a batch is queued at once (history.record_reported()) and counted once per set of labels.
|
|
1252
|
+
|
|
1253
|
+
`rate_limited`: the calls it refused itself with a 429 (ADR 0032) - by bucket, caller and method, one duration
|
|
1254
|
+
each - counted as gate()'s own refusals are: in the rejections, the alerts and the request counters. Not runs."""
|
|
1240
1255
|
report = request.get_json(silent=True) or {}
|
|
1241
1256
|
runs = report.get('runs') or []
|
|
1257
|
+
for refused in report.get('rate_limited') or []:
|
|
1258
|
+
governance.note_refused(refused['scope'], refused.get('client'), refused.get('workspace'), refused.get('key'),
|
|
1259
|
+
refused['method'], refused['seconds'])
|
|
1242
1260
|
broadcaster = current_app.extensions['queryapigate_broadcaster']
|
|
1243
1261
|
with scope.using(scope.current()): # resolved once, not by each of a busy batch's few thousand label lookups
|
|
1244
1262
|
if report.get('recorded'): # in the run store already: written by the data plane itself (ADR 0029 §4)
|
|
@@ -127,8 +127,6 @@ def _init(args):
|
|
|
127
127
|
inactive) now that connections are SQLite-backed - no more db_connections.json/saved_sql/ for a new
|
|
128
128
|
install. On a home whose store already has connections, this is a no-op - "already exists - left
|
|
129
129
|
untouched"."""
|
|
130
|
-
home = config.home()
|
|
131
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
132
130
|
if db.connection().execute('SELECT 1 FROM connections LIMIT 1').fetchone() is not None:
|
|
133
131
|
print(f'{db.describe()} already has connections - left untouched')
|
|
134
132
|
return 0
|
|
@@ -903,7 +901,7 @@ def build_parser():
|
|
|
903
901
|
collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
|
|
904
902
|
collection_import.set_defaults(func=_collection_import)
|
|
905
903
|
|
|
906
|
-
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014
|
|
904
|
+
config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014): "
|
|
907
905
|
'export it, see what applying one would change, apply it')
|
|
908
906
|
config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
|
|
909
907
|
config_actions = config_parser.add_subparsers(dest='action')
|
|
@@ -1041,6 +1039,8 @@ def main(argv=None):
|
|
|
1041
1039
|
os.environ['QUERYAPIGATE_HOME'] = args.home
|
|
1042
1040
|
if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
|
|
1043
1041
|
return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
|
|
1042
|
+
if getattr(args, 'func', None) is _init: # the one command that makes a home: the store below opens inside it
|
|
1043
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
1044
1044
|
try:
|
|
1045
1045
|
config.check_database_url()
|
|
1046
1046
|
db.init_schema()
|
|
@@ -21,6 +21,11 @@ which is slower but answers as Python does.
|
|
|
21
21
|
|
|
22
22
|
Saved queries with `{name}` text placeholders never get a plan: their values are part of the SQL text, so the verdict
|
|
23
23
|
can change from one call to the next.
|
|
24
|
+
|
|
25
|
+
With Redis (QUERYAPIGATE_REDIS_URL), a key's rate limit and the server-wide QUERYAPIGATE_RATE_LIMIT are counted by the
|
|
26
|
+
data plane too, in the same buckets (ADR 0032): the plan carries the key's limit, and the data plane counts a call
|
|
27
|
+
only once it is going to serve it. A call it forwards after counting says so (X-QAG-Rate-Counted), and is not counted
|
|
28
|
+
again here. Its refusals reach /metrics and the alerts with its runs report.
|
|
24
29
|
"""
|
|
25
30
|
import base64
|
|
26
31
|
import hmac
|
|
@@ -37,6 +42,7 @@ PEER_HEADER = 'X-QAG-Peer'
|
|
|
37
42
|
WANTED_HEADER = 'X-QAG-Plan-Wanted'
|
|
38
43
|
VERSION_HEADER = 'X-QAG-Dataplane-Version' # the data plane's release, sent with its secret
|
|
39
44
|
PLAN_HEADER = 'X-QAG-Plan'
|
|
45
|
+
COUNTED_HEADER = 'X-QAG-Rate-Counted' # the rate-limit buckets a call the data plane forwards was counted in already
|
|
40
46
|
VERSION = 1
|
|
41
47
|
DIALECTS = ('postgres', 'mysql') # the two the prototype serves - every other connection is forwarded
|
|
42
48
|
_ROWS = ('api_key', 'saved_query', 'connection') # what a plan names the digests of, one row set each
|
|
@@ -47,6 +53,7 @@ PEER_ENVIRON = 'HTTP_X_QAG_PEER'
|
|
|
47
53
|
# The data plane asks for a plan only when it would keep one: other calls it forwards keep the metadata cache
|
|
48
54
|
WANTED_ENVIRON = 'HTTP_X_QAG_PLAN_WANTED'
|
|
49
55
|
VERSION_ENVIRON = 'HTTP_X_QAG_DATAPLANE_VERSION'
|
|
56
|
+
COUNTED_ENVIRON = 'HTTP_X_QAG_RATE_COUNTED'
|
|
50
57
|
UNPAIRED = ('This data plane is from another release than this server: it gets no plans and no run store lease '
|
|
51
58
|
'until both are the same release')
|
|
52
59
|
_unpaired_seen: set = set()
|
|
@@ -80,11 +87,29 @@ def is_address(peer):
|
|
|
80
87
|
|
|
81
88
|
def eligible_caller(permission):
|
|
82
89
|
"""A caller the data plane may serve: a scoped API key - not an administrator, a signed-in user or an open server -
|
|
83
|
-
in the default workspace,
|
|
90
|
+
in the default workspace, without the limits that keep state: no quota or max_concurrent, and a rate limit only
|
|
91
|
+
when Redis holds its bucket, where the data plane counts it too (ADR 0032). Without Redis each process would count
|
|
92
|
+
its own budget. A query_timeout and max_rows keep none: the plan carries them, and the data plane applies each as
|
|
93
|
+
limits.py does."""
|
|
84
94
|
return (permission is not None and not permission.admin and permission.claims is None and permission.role is None
|
|
85
95
|
and not permission.claim_binding and permission.workspace == 'default'
|
|
86
|
-
and permission.rate_limit is None
|
|
87
|
-
and permission.
|
|
96
|
+
and (permission.rate_limit is None or config.redis_url() is not None)
|
|
97
|
+
and permission.quota is None and permission.max_concurrent is None)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def counted(value):
|
|
101
|
+
"""X-QAG-Rate-Counted (`client=<limit>,<remaining>; key=<limit>,<remaining>`, either or both) as
|
|
102
|
+
{'client' | 'key': (limit, remaining)}: the buckets the data plane counted this call in before forwarding it, and
|
|
103
|
+
what they said. {} for a value of any other shape."""
|
|
104
|
+
found = {}
|
|
105
|
+
for part in (value or '').split(';'):
|
|
106
|
+
bucket, _, numbers = part.strip().partition('=')
|
|
107
|
+
limit, _, remaining = numbers.partition(',')
|
|
108
|
+
if bucket not in ('client', 'key') or bucket in found or not (limit.isascii() and limit.isdigit()
|
|
109
|
+
and remaining.isascii() and remaining.isdigit()):
|
|
110
|
+
return {}
|
|
111
|
+
found[bucket] = (int(limit), int(remaining))
|
|
112
|
+
return found
|
|
88
113
|
|
|
89
114
|
|
|
90
115
|
def digests(record):
|
|
@@ -161,6 +186,11 @@ def build(record, permission, query_name, number, saved, connection_name, result
|
|
|
161
186
|
return None
|
|
162
187
|
mask = {'columns': len(masked.traced), 'actions': masked.actions, 'fired': masked.fired}
|
|
163
188
|
return {'v': VERSION, 'rows': rows, 'key': {'workspace': permission.workspace, 'name': permission.name},
|
|
189
|
+
# (count, period in seconds): counted by the data plane in Redis, in the bucket Python counts it in
|
|
190
|
+
'rate_limit': list(permission.rate_limit) if permission.rate_limit else None,
|
|
191
|
+
# the key's own limits (ADR 0011), applied as limits.timeout() and limits.rows() apply them; an int
|
|
192
|
+
# query_timeout stays one, as min() keeps it
|
|
193
|
+
'query_timeout': permission.query_timeout, 'max_rows': permission.max_rows,
|
|
164
194
|
'query': {'name': query_name, 'version': number}, 'connection': {'name': connection_name,
|
|
165
195
|
'dialect': dialect}, 'statement': sent, 'search_path': prepared['search_path'], 'mask': mask,
|
|
166
196
|
'expires_in': expires_in,
|
|
@@ -13,7 +13,7 @@ process - `queryapigate serve`, `queryapigate mcp`, `queryapigate events` - shar
|
|
|
13
13
|
import ipaddress
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
|
|
16
|
-
from . import admins, alerts, apikeys, config, jwtauth, metrics
|
|
16
|
+
from . import admins, alerts, apikeys, config, jwtauth, metrics, ratelimit
|
|
17
17
|
|
|
18
18
|
OPEN_ACCESS_REMOTE = ('This server has no API key set, so it answers only local clients. Set QUERYAPIGATE_API_KEY '
|
|
19
19
|
'to allow access from other hosts.')
|
|
@@ -98,13 +98,26 @@ def check_key_limit(flask_app, permission):
|
|
|
98
98
|
return None
|
|
99
99
|
count, period = permission.rate_limit
|
|
100
100
|
allowed, remaining, retry_after = flask_app.extensions['queryapigate_key_limiter'].hit(
|
|
101
|
-
permission
|
|
101
|
+
ratelimit.budget_name(permission), count, period)
|
|
102
102
|
if not allowed:
|
|
103
103
|
metrics.inc_rate_limit_rejection()
|
|
104
104
|
alerts.note_rate_limited('key', (getattr(permission, 'workspace', 'default'), permission.name))
|
|
105
105
|
return Verdict(allowed, count, remaining, retry_after)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def note_refused(scope, client_ip, workspace, key, method, elapsed):
|
|
109
|
+
"""429s the data plane answered itself (ADR 0032), one duration each: counted as check_client_limit() and
|
|
110
|
+
check_key_limit() count their own - the rejections and the alerts - and in the request counters, as the calls they
|
|
111
|
+
were. `scope` is 'client' (by `client_ip`, before the caller is known: no key label) or 'key'."""
|
|
112
|
+
if scope == 'client':
|
|
113
|
+
who, label = client_ip, '-'
|
|
114
|
+
else:
|
|
115
|
+
who, label = (workspace or 'default', key), key or '-'
|
|
116
|
+
metrics.inc_rate_limit_rejection(len(elapsed))
|
|
117
|
+
alerts.note_rate_limited(scope, who, len(elapsed))
|
|
118
|
+
metrics.observe_requests(method, 'api.run_named_query', '429', elapsed, label)
|
|
119
|
+
|
|
120
|
+
|
|
108
121
|
def observe(method, endpoint, status, elapsed, key):
|
|
109
122
|
"""One finished call in /metrics' request counters and latency histograms. REST passes the HTTP method and its
|
|
110
123
|
route's endpoint; MCP passes method 'MCP' and an 'mcp.*' endpoint, so the two can be told apart."""
|
|
@@ -229,10 +229,11 @@ def acquire(query_timeout=None):
|
|
|
229
229
|
lease = (query_timeout or LEASE_WITHOUT_TIMEOUT) + LEASE_MARGIN
|
|
230
230
|
token = secrets.token_hex(8)
|
|
231
231
|
store = counter()
|
|
232
|
-
|
|
232
|
+
name = ratelimit.budget_name(permission)
|
|
233
|
+
if not store.acquire(name, maximum, token, lease):
|
|
233
234
|
raise ApiError(f'This caller already has {maximum} queries running, its max_concurrent - retry when one '
|
|
234
235
|
'finishes', 429, code='too_many_concurrent', retry_after=1, max_concurrent=maximum)
|
|
235
|
-
return Slot(
|
|
236
|
+
return Slot(name, token, lease, store)
|
|
236
237
|
|
|
237
238
|
|
|
238
239
|
def effective(grants):
|
|
@@ -344,7 +344,9 @@ def call_tool_for(flask_app, permission, name, arguments):
|
|
|
344
344
|
structured = g.agent_write_result
|
|
345
345
|
return {'content': [{'type': 'text', 'text': json.dumps(structured, default=str)}],
|
|
346
346
|
'structuredContent': structured}
|
|
347
|
-
|
|
347
|
+
# REST answers no rows with {"message": "No results returned"}; a tool's rows are a list (its outputSchema)
|
|
348
|
+
rows = response.get_json() if response.headers.get('X-Row-Count') != '0' else []
|
|
349
|
+
structured = {'rows': rows, 'truncated': response.headers.get('X-Has-More') == 'true'}
|
|
348
350
|
return _rows_result(structured)
|
|
349
351
|
|
|
350
352
|
|
|
@@ -180,10 +180,10 @@ def dec_active_query():
|
|
|
180
180
|
_active_queries -= 1
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def inc_rate_limit_rejection():
|
|
183
|
+
def inc_rate_limit_rejection(count=1):
|
|
184
184
|
global _rate_limit_rejections
|
|
185
185
|
with _lock:
|
|
186
|
-
_rate_limit_rejections +=
|
|
186
|
+
_rate_limit_rejections += count
|
|
187
187
|
|
|
188
188
|
|
|
189
189
|
def inc_rate_limit_fallback():
|
|
@@ -120,6 +120,15 @@ end
|
|
|
120
120
|
return {allowed, math.floor(tokens), retry}
|
|
121
121
|
"""
|
|
122
122
|
_PREFIX = 'qag:ratelimit:'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def budget_name(permission):
|
|
126
|
+
"""What a caller's own budgets - its rate limit's bucket, its max_concurrent slots (limits.py) - are counted
|
|
127
|
+
under: `<workspace>~<name>`, as quotas count theirs. A name is unique only within its workspace, so two workspaces'
|
|
128
|
+
keys of one name are two callers; no workspace name has a `~`."""
|
|
129
|
+
return f"{getattr(permission, 'workspace', None) or 'default'}~{permission.name}"
|
|
130
|
+
|
|
131
|
+
|
|
123
132
|
_DEGRADED_FOR = 600 # seconds a Redis failure keeps the "limits are per instance right now" alert up
|
|
124
133
|
_degraded_at = None
|
|
125
134
|
_last_warned = 0.0
|
|
@@ -270,7 +270,11 @@ def paginate(sql, limit, offset, params=None, dialect=None):
|
|
|
270
270
|
# Parameters
|
|
271
271
|
# --------------------------------------------------------------------------------------
|
|
272
272
|
|
|
273
|
-
def _check_value(name, value):
|
|
273
|
+
def _check_value(name, value, dialect=None):
|
|
274
|
+
if isinstance(value, str) and dialect == 'postgres' and '\x00' in value:
|
|
275
|
+
# psycopg2 refuses it ("A string literal cannot contain NUL") - the caller's value, not the query, is wrong
|
|
276
|
+
raise ApiError(f"Parameter '{name}' contains a NUL character, which PostgreSQL text can't hold",
|
|
277
|
+
code='param_invalid')
|
|
274
278
|
if value is None or isinstance(value, (bool, str)):
|
|
275
279
|
return
|
|
276
280
|
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
@@ -344,7 +348,7 @@ def bind_parameters(sql, params, style, dialect=None):
|
|
|
344
348
|
if missing:
|
|
345
349
|
raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}", code='param_required')
|
|
346
350
|
for name in set(names):
|
|
347
|
-
_check_value(name, params[name])
|
|
351
|
+
_check_value(name, params[name], dialect)
|
|
348
352
|
|
|
349
353
|
escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
|
|
350
354
|
pieces, position = [], 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.48.0
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -199,5 +199,18 @@ class LegacySettingsTests(unittest.TestCase):
|
|
|
199
199
|
self.assertEqual(config.api_key(), 'secret')
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
class InitTests(unittest.TestCase):
|
|
203
|
+
def test_init_makes_a_home_that_doesn_t_exist_yet(self):
|
|
204
|
+
tmp = tempfile.TemporaryDirectory()
|
|
205
|
+
self.addCleanup(tmp.cleanup)
|
|
206
|
+
home = os.path.join(tmp.name, 'new', 'home')
|
|
207
|
+
out = io.StringIO()
|
|
208
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_HOME': home}), redirect_stdout(out):
|
|
209
|
+
self.assertEqual(cli.main(['init']), 0)
|
|
210
|
+
self.assertTrue(store.read_connections()) # the templates, in the store it made
|
|
211
|
+
self.assertTrue(os.path.isdir(home))
|
|
212
|
+
self.assertIn('Created', out.getvalue())
|
|
213
|
+
|
|
214
|
+
|
|
202
215
|
if __name__ == '__main__':
|
|
203
216
|
unittest.main()
|
|
@@ -115,9 +115,38 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
115
115
|
**PAIRED})
|
|
116
116
|
self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
|
|
117
117
|
|
|
118
|
-
def
|
|
118
|
+
def test_never_for_a_key_with_a_rate_limit_without_redis(self):
|
|
119
119
|
self.assertIsNone(self.call('/q/by_id?id=1', self.limited)[1])
|
|
120
120
|
|
|
121
|
+
def test_with_redis_a_rate_limited_key_gets_one_that_carries_its_limit(self):
|
|
122
|
+
# Its bucket is in Redis, where the data plane counts it too (ADR 0032): the plan says what the limit is
|
|
123
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
|
|
124
|
+
plan = self.call('/q/by_id?id=1', self.limited)[1]
|
|
125
|
+
self.assertEqual(plan['rate_limit'], [100, 60])
|
|
126
|
+
self.assertIsNone(self.call('/q/by_id?id=1', self.plain)[1]['rate_limit'])
|
|
127
|
+
|
|
128
|
+
def test_never_for_the_limits_that_keep_their_own_state_with_redis_or_without(self):
|
|
129
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
|
|
130
|
+
for name, grants in (('quota', {'quota': {'calls': 100, 'per': 'day'}}), ('slots', {'max_concurrent': 2})):
|
|
131
|
+
with self.subTest(name):
|
|
132
|
+
key = apikeys.create_key(name, connections=['pg'], **grants)
|
|
133
|
+
self.assertIsNone(self.call('/q/by_id?id=1', key)[1])
|
|
134
|
+
|
|
135
|
+
def test_a_key_s_own_time_and_row_limits_travel_in_its_plan(self):
|
|
136
|
+
# They keep no state: the data plane applies each as limits.py does. A whole-number timeout stays an int,
|
|
137
|
+
# as Python's min() keeps it (its 504 says 5, not 5.0)
|
|
138
|
+
for name, grants, expected in (('timeout', {'query_timeout': 5}, (5, None)),
|
|
139
|
+
('fraction', {'query_timeout': 0.5}, (0.5, None)),
|
|
140
|
+
('rows', {'max_rows': 10}, (None, 10)),
|
|
141
|
+
('both', {'query_timeout': 2, 'max_rows': 3}, (2, 3))):
|
|
142
|
+
with self.subTest(name):
|
|
143
|
+
key = apikeys.create_key(name, connections=['pg'], **grants)
|
|
144
|
+
plan = self.call('/q/by_id?id=1', key)[1]
|
|
145
|
+
self.assertEqual((plan['query_timeout'], plan['max_rows']), expected)
|
|
146
|
+
self.assertIs(type(plan['query_timeout']), type(expected[0]))
|
|
147
|
+
plain = self.call('/q/by_id?id=1', self.plain)[1]
|
|
148
|
+
self.assertEqual((plain['query_timeout'], plain['max_rows']), (None, None))
|
|
149
|
+
|
|
121
150
|
def test_never_on_a_connection_the_prototype_does_not_serve(self):
|
|
122
151
|
self.assertIsNone(self.call('/q/lite_by_id?id=1', self.plain)[1])
|
|
123
152
|
|
|
@@ -179,11 +208,11 @@ class WhoGetsAPlanTests(PlanTestCase):
|
|
|
179
208
|
dockerfile = f.read()
|
|
180
209
|
rust = re.search(r'^FROM --platform=\$BUILDPLATFORM rust:([\d.]+)-slim-(\w+)@sha256:\w+ AS dataplane$',
|
|
181
210
|
dockerfile, re.M)
|
|
182
|
-
|
|
211
|
+
image = re.search(r'^FROM debian:(\w+)-slim@sha256:\w+$', dockerfile, re.M) # Debian's Python (ADR 0031)
|
|
183
212
|
self.assertIsNotNone(rust, 'the data plane stage: rust:<channel>-slim-<debian>@sha256:..., by digest')
|
|
184
|
-
self.assertIsNotNone(
|
|
213
|
+
self.assertIsNotNone(image, 'the image: debian:<release>-slim@sha256:..., by digest')
|
|
185
214
|
self.assertEqual(rust.group(1), channel)
|
|
186
|
-
self.assertEqual(rust.group(2),
|
|
215
|
+
self.assertEqual(rust.group(2), image.group(1), 'the data plane is built on the Debian release it runs on')
|
|
187
216
|
self.assertNotIn('COPY dataplane/ ', dockerfile)
|
|
188
217
|
self.assertIn('COPY --from=dataplane /queryapigate-dataplane /usr/local/bin/queryapigate-dataplane',
|
|
189
218
|
dockerfile)
|
|
@@ -398,6 +427,57 @@ class ClientAddressTests(unittest.TestCase):
|
|
|
398
427
|
self.assertEqual(self.get('/catalog', peer='192.0.2.1', **{'X-API-Key': key}).status_code, 401)
|
|
399
428
|
|
|
400
429
|
|
|
430
|
+
class CountedByTheDataPlaneTests(PlanTestCase):
|
|
431
|
+
"""A call the data plane forwards after counting it (ADR 0032, section 4): X-QAG-Rate-Counted names the buckets
|
|
432
|
+
and what they said, and Python counts them no more - but only beside the data plane's secret."""
|
|
433
|
+
|
|
434
|
+
def get(self, key, counted, secret=SECRET):
|
|
435
|
+
headers = {'X-API-Key': key, dataplane.COUNTED_HEADER: counted}
|
|
436
|
+
if secret:
|
|
437
|
+
headers.update({dataplane.SECRET_HEADER: secret, **PAIRED})
|
|
438
|
+
return self.client.get('/q/by_id?id=1', headers=headers)
|
|
439
|
+
|
|
440
|
+
def test_the_header_as_the_data_plane_writes_it(self):
|
|
441
|
+
self.assertEqual(dataplane.counted('client=5,3; key=100,42'), {'client': (5, 3), 'key': (100, 42)})
|
|
442
|
+
self.assertEqual(dataplane.counted('key=100,0'), {'key': (100, 0)})
|
|
443
|
+
for odd in ('', 'key=100', 'key=-1,2', 'key=1,2; key=1,2', 'other=1,2', 'key=١,2', 'key=1,2;', None):
|
|
444
|
+
with self.subTest(odd):
|
|
445
|
+
self.assertEqual(dataplane.counted(odd), {})
|
|
446
|
+
|
|
447
|
+
def test_a_key_s_bucket_counted_by_the_data_plane_is_not_counted_again(self):
|
|
448
|
+
res = self.get(self.limited, 'key=100,42')
|
|
449
|
+
self.assertEqual(res.status_code, 200)
|
|
450
|
+
self.assertEqual((res.headers['X-RateLimit-Key-Limit'], res.headers['X-RateLimit-Key-Remaining']),
|
|
451
|
+
('100', '42'))
|
|
452
|
+
res = self.get(self.limited, None, secret=None)
|
|
453
|
+
self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99') # Python's own bucket: one call, this one
|
|
454
|
+
|
|
455
|
+
def test_nor_the_client_s(self):
|
|
456
|
+
with mock.patch.dict(os.environ, {'QUERYAPIGATE_RATE_LIMIT': '5/minute'}):
|
|
457
|
+
res = self.get(self.limited, 'client=5,0; key=100,7')
|
|
458
|
+
self.assertEqual(res.status_code, 200)
|
|
459
|
+
names = ('Limit', 'Remaining', 'Key-Limit', 'Key-Remaining')
|
|
460
|
+
self.assertEqual([res.headers[f'X-RateLimit-{name}'] for name in names], ['5', '0', '100', '7'])
|
|
461
|
+
# Only the client's counted there (Redis failed for the key's): Python counts the key's
|
|
462
|
+
res = self.get(self.limited, 'client=5,4')
|
|
463
|
+
self.assertEqual((res.headers['X-RateLimit-Remaining'], res.headers['X-RateLimit-Key-Remaining']),
|
|
464
|
+
('4', '99'))
|
|
465
|
+
|
|
466
|
+
def test_without_the_secret_python_counts_as_ever(self):
|
|
467
|
+
remaining = [self.get(self.limited, 'key=100,42', secret=secret).headers['X-RateLimit-Key-Remaining']
|
|
468
|
+
for secret in (None, 'wrong')]
|
|
469
|
+
self.assertEqual(remaining, ['99', '98'])
|
|
470
|
+
|
|
471
|
+
def test_never_from_a_data_plane_of_another_release(self):
|
|
472
|
+
res = self.client.get('/q/by_id?id=1', headers={'X-API-Key': self.limited, dataplane.SECRET_HEADER: SECRET,
|
|
473
|
+
dataplane.COUNTED_HEADER: 'key=100,50'})
|
|
474
|
+
self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99')
|
|
475
|
+
|
|
476
|
+
def test_a_key_without_a_limit_gets_no_limit_headers_from_it(self):
|
|
477
|
+
res = self.get(self.plain, 'key=100,42')
|
|
478
|
+
self.assertNotIn('X-RateLimit-Key-Limit', res.headers)
|
|
479
|
+
|
|
480
|
+
|
|
401
481
|
class RunReportTests(PlanTestCase):
|
|
402
482
|
"""The data plane reports the runs it served (POST /internal/dataplane/runs): each recorded as run_saved() records
|
|
403
483
|
one, and counted in /metrics - and nothing without its secret."""
|
|
@@ -410,6 +490,11 @@ class RunReportTests(PlanTestCase):
|
|
|
410
490
|
'dialect': 'postgres', 'method': 'GET', 'http_status': 200, 'seconds': 0.002, 'query_seconds': 0.001,
|
|
411
491
|
'serialization_seconds': 0.0001}
|
|
412
492
|
|
|
493
|
+
def setUp(self):
|
|
494
|
+
super().setUp()
|
|
495
|
+
from queryapigate import alerts
|
|
496
|
+
self.addCleanup(alerts._refusals.clear) # the refusals reported here are the process's, for its alerts
|
|
497
|
+
|
|
413
498
|
def report(self, secret=SECRET):
|
|
414
499
|
headers = {dataplane.SECRET_HEADER: secret} if secret else {}
|
|
415
500
|
return self.client.post('/internal/dataplane/runs', json={'runs': [self.RUN]}, headers=headers)
|
|
@@ -453,6 +538,46 @@ class RunReportTests(PlanTestCase):
|
|
|
453
538
|
self.assertEqual(metrics.history_counts().get('dropped', 0) - before, 7)
|
|
454
539
|
self.assertEqual(self.history(), [self.ENTRY])
|
|
455
540
|
|
|
541
|
+
def test_the_429s_it_answered_are_counted_as_python_s_own(self):
|
|
542
|
+
# ADR 0032, section 5: in the rejections, the alerts and the request counters - and none is a run
|
|
543
|
+
from queryapigate import alerts
|
|
544
|
+
|
|
545
|
+
def counts():
|
|
546
|
+
text = self.client.get('/metrics', headers=ADMIN).get_data(as_text=True)
|
|
547
|
+
found = []
|
|
548
|
+
for name in ('queryapigate_rate_limit_rejections_total',
|
|
549
|
+
'queryapigate_requests_total{method="GET",endpoint="api.run_named_query",status="429",'
|
|
550
|
+
'key="limited"}',
|
|
551
|
+
'queryapigate_requests_total{method="POST",endpoint="api.run_named_query",status="429",'
|
|
552
|
+
'key="-"}'):
|
|
553
|
+
match = re.search(re.escape(name) + r' (\d+)', text)
|
|
554
|
+
found.append(int(match.group(1)) if match else 0)
|
|
555
|
+
return found
|
|
556
|
+
|
|
557
|
+
def refusals(who):
|
|
558
|
+
with alerts._lock:
|
|
559
|
+
return len(alerts._refusals.get(who, ()))
|
|
560
|
+
before, key, client = counts(), refusals(('key', ('default', 'limited'))), refusals(('client', '203.0.113.9'))
|
|
561
|
+
res = self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
|
|
562
|
+
'runs': [], 'dropped': 0, 'rate_limited': [
|
|
563
|
+
{'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET', 'seconds': [0.001, 0.002]},
|
|
564
|
+
{'scope': 'client', 'client': '203.0.113.9', 'method': 'POST', 'seconds': [0.001]}]})
|
|
565
|
+
self.assertEqual(res.status_code, 204)
|
|
566
|
+
self.assertEqual([after - was for after, was in zip(counts(), before, strict=True)], [3, 2, 1])
|
|
567
|
+
self.assertEqual(refusals(('key', ('default', 'limited'))) - key, 2)
|
|
568
|
+
self.assertEqual(refusals(('client', '203.0.113.9')) - client, 1)
|
|
569
|
+
self.assertEqual(self.history(), [])
|
|
570
|
+
|
|
571
|
+
def test_a_key_that_keeps_getting_them_raises_its_alert(self):
|
|
572
|
+
from queryapigate import alerts
|
|
573
|
+
seconds = [0.001] * alerts.RATE_LIMITED_PER_HOUR
|
|
574
|
+
self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
|
|
575
|
+
'runs': [], 'rate_limited': [{'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET',
|
|
576
|
+
'seconds': seconds}]})
|
|
577
|
+
raised = [a for a in self.client.get('/api/v1/alerts', headers=ADMIN).get_json()['items']
|
|
578
|
+
if a['kind'] == 'key_rate_limited']
|
|
579
|
+
self.assertEqual([a['id'] for a in raised], ['key_rate_limited:limited'])
|
|
580
|
+
|
|
456
581
|
def test_its_event_is_the_one_python_writes(self):
|
|
457
582
|
from queryapigate import events
|
|
458
583
|
self.assertEqual(self.report().status_code, 204)
|
|
@@ -153,6 +153,17 @@ class IntegrationBase:
|
|
|
153
153
|
self.assertEqual(len(res.get_json()), 1)
|
|
154
154
|
self.assertEqual(len(self.query('SELECT * FROM queryapigate_it', '?page_size=100').get_json()), 7)
|
|
155
155
|
|
|
156
|
+
def test_a_nul_in_a_bound_parameter_is_never_a_500(self):
|
|
157
|
+
"""A NUL in a string parameter: a value the database compares as it is, or - PostgreSQL's text can't hold
|
|
158
|
+
one, and psycopg2 refuses to send it - the caller's 400 naming the parameter."""
|
|
159
|
+
res = self.query('SELECT id FROM queryapigate_it WHERE name = :name', params={'name': 'Item\x003'})
|
|
160
|
+
if self.db == 'postgres':
|
|
161
|
+
self.assertEqual((res.status_code, res.get_json()['code']), (400, 'param_invalid'))
|
|
162
|
+
self.assertIn("'name'", res.get_json()['error'])
|
|
163
|
+
else:
|
|
164
|
+
self.assertEqual(res.status_code, 200, res.get_data(as_text=True)) # and no row: none has a NUL
|
|
165
|
+
self.assertNotIsInstance(res.get_json(), list)
|
|
166
|
+
|
|
156
167
|
def test_all_formats(self):
|
|
157
168
|
for fmt in ('json', 'ndjson', 'csv', 'tsv', 'xml', 'yaml', 'xlsx'):
|
|
158
169
|
res = self.query('SELECT * FROM queryapigate_it ORDER BY id', f'?format={fmt}&page_size=2')
|
|
@@ -275,6 +275,12 @@ class CallToolForTests(McpTestCase):
|
|
|
275
275
|
result = mcp_server.call_tool_for(self.app, apikeys.OPEN, 'by_id', {'id': 3})
|
|
276
276
|
self.assertEqual(result['structuredContent']['rows'], [{'id': 3, 'name': 'row3'}])
|
|
277
277
|
|
|
278
|
+
def test_no_rows_is_an_empty_list_not_rest_s_message(self):
|
|
279
|
+
self.save('by_id', sql='SELECT * FROM t WHERE id = :id')
|
|
280
|
+
result = mcp_server.call_tool_for(self.app, apikeys.OPEN, 'by_id', {'id': 99})
|
|
281
|
+
self.assertEqual(result['structuredContent'], {'rows': [], 'truncated': False})
|
|
282
|
+
self.assertEqual(json.loads(result['content'][0]['text']), result['structuredContent'])
|
|
283
|
+
|
|
278
284
|
def test_a_connection_grant_violation_is_reported_as_an_error_result(self):
|
|
279
285
|
self.save('q1')
|
|
280
286
|
key = self.create_scoped_key(connections=[]) # no reach to connection 'a' or the query by name
|
|
@@ -553,6 +559,7 @@ class EndToEndMcpTests(McpTestCase):
|
|
|
553
559
|
|
|
554
560
|
def test_a_real_client_can_list_and_call_a_tool(self):
|
|
555
561
|
self.save('q1')
|
|
562
|
+
self.save('by_id', sql='SELECT * FROM t WHERE id = :id')
|
|
556
563
|
port = self.start_server()
|
|
557
564
|
|
|
558
565
|
import asyncio
|
|
@@ -568,9 +575,13 @@ class EndToEndMcpTests(McpTestCase):
|
|
|
568
575
|
tools = await session.list_tools()
|
|
569
576
|
names = [t.name for t in tools.tools]
|
|
570
577
|
result = await session.call_tool('q1', {})
|
|
571
|
-
|
|
578
|
+
# no rows: the SDK checks structuredContent against the outputSchema, which wants a list
|
|
579
|
+
empty = await session.call_tool('by_id', {'id': '99'})
|
|
580
|
+
return names, result, empty
|
|
572
581
|
|
|
573
|
-
names, result = asyncio.run(talk())
|
|
582
|
+
names, result, empty = asyncio.run(talk())
|
|
583
|
+
self.assertFalse(empty.isError, empty.content)
|
|
584
|
+
self.assertEqual(empty.structuredContent, {'rows': [], 'truncated': False})
|
|
574
585
|
self.assertIn('q1', names)
|
|
575
586
|
self.assertIn('list_tables', names)
|
|
576
587
|
self.assertIn('execute_sql', names)
|
|
@@ -1499,6 +1499,17 @@ class ParameterTests(ApiTestCase):
|
|
|
1499
1499
|
# no parameters: statement is untouched so drivers do not apply % formatting to it
|
|
1500
1500
|
self.assertEqual(sqltools.bind_parameters("SELECT '50%'", {}, 'format'), ("SELECT '50%'", None))
|
|
1501
1501
|
|
|
1502
|
+
def test_a_nul_in_a_string_parameter_is_refused_for_postgresql_only(self):
|
|
1503
|
+
"""psycopg2 can't send one (PostgreSQL text can't hold it), so it is the caller's 400, not a failed query's
|
|
1504
|
+
500; the other databases take the value as it is."""
|
|
1505
|
+
with self.assertRaises(ApiError) as caught:
|
|
1506
|
+
sqltools.bind_parameters('SELECT :a, :b', {'a': 'fine', 'b': 'x\x00y'}, 'format', 'postgres')
|
|
1507
|
+
self.assertEqual((caught.exception.status, caught.exception.code), (400, 'param_invalid'))
|
|
1508
|
+
self.assertIn("'b'", caught.exception.message)
|
|
1509
|
+
for dialect in ('mysql', 'duckdb', 'clickhouse', None):
|
|
1510
|
+
self.assertEqual(sqltools.bind_parameters('SELECT :b', {'b': 'x\x00y'}, 'qmark', dialect)[1], ['x\x00y'])
|
|
1511
|
+
self.assertEqual(sqltools.bind_parameters('SELECT :b', {'b': 'x'}, 'format', 'postgres')[1], ['x'])
|
|
1512
|
+
|
|
1502
1513
|
|
|
1503
1514
|
FOREVER = 'WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT count(*) FROM c'
|
|
1504
1515
|
|
|
@@ -136,6 +136,18 @@ class CountingTests(QuotaTestCase):
|
|
|
136
136
|
self.assertEqual((refused['isError'], refused['structuredContent']['code']), (True, 'quota_exceeded'))
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
def test_an_mcp_call_with_no_rows_counts_none(self):
|
|
140
|
+
res = save_query(self.client, {'author': 'a', 'description': 'd', 'filename': 'none', 'connection_name': 'lite',
|
|
141
|
+
'sql_query': 'SELECT id FROM t WHERE id < 0'}, headers=ADMIN)
|
|
142
|
+
self.assertEqual(res.status_code, 201, res.get_data(as_text=True))
|
|
143
|
+
quota = {'rows': 100, 'per': 'day'}
|
|
144
|
+
permission = apikeys.authenticate(secret_of(create_key(self.client, ADMIN, name='agent', queries=['none'],
|
|
145
|
+
connections=['lite'], quota=quota)))
|
|
146
|
+
self.assertFalse(mcp_server.call_as(self.app, permission, 'none', {}).get('isError'))
|
|
147
|
+
used = quotas.usage('agent', quota)
|
|
148
|
+
self.assertEqual((used['calls'], used['rows']), (1, 0)) # not 1: REST's "No results returned" isn't a row
|
|
149
|
+
|
|
150
|
+
|
|
139
151
|
class StoreTests(QuotaTestCase):
|
|
140
152
|
env = {'QUERYAPIGATE_HISTORY_FLUSH_INTERVAL': '5'}
|
|
141
153
|
|