Skip to content

Commit c89e959

Browse files
StpMaxlucas-koontzMinuraPunchihewatorrmaltino097
authored
Release 26.2.0 (#12402)
Co-authored-by: Lucas Koontz <lucas.emanuel.koontz@gmail.com> Co-authored-by: Minura Punchihewa <49385643+MinuraPunchihewa@users.noreply.github.com> Co-authored-by: Jorge Torres <jorge.torres.maldonado@gmail.com> Co-authored-by: Konstantin Sivakov <konstantin.sivakov@gmail.com> Co-authored-by: Zoran Pandovski <zoran.pandovski@gmail.com>
1 parent 86be14b commit c89e959

23 files changed

Lines changed: 2279 additions & 29 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
---
2+
title: REST API
3+
sidebarTitle: REST API
4+
---
5+
6+
In this section, we present how to connect any REST API to MindsDB using bearer-token authentication.
7+
8+
The REST API handler is a generic integration that lets you forward HTTP requests to any API through MindsDB using stored credentials. Unlike named integrations (HubSpot, Shopify, etc.), it requires no handler-specific knowledge — just a base URL and a bearer token.
9+
10+
This is useful for APIs that MindsDB doesn't have a dedicated handler for, or when you only need direct HTTP access without SQL table mapping.
11+
12+
## Connection
13+
14+
The required arguments to establish a connection are as follows:
15+
16+
- `base_url`: the base URL of the REST API (e.g. `https://api.example.com`). All request paths are appended to this URL.
17+
- `bearer_token`: the token used for authentication. Injected as `Authorization: Bearer <token>` on every request.
18+
19+
Optional arguments:
20+
21+
- `default_headers`: a JSON object of static headers added to every request (e.g. `{"Accept": "application/json"}`).
22+
- `allowed_hosts`: a list of allowed hostnames for requests. Defaults to the hostname of `base_url`. Use `["*"]` to disable host containment.
23+
- `test_path`: the path used by the test endpoint to verify connectivity. Defaults to `/`.
24+
25+
To connect a REST API to MindsDB, create a new database:
26+
27+
```sql
28+
CREATE DATABASE my_api
29+
WITH ENGINE = 'rest_api',
30+
PARAMETERS = {
31+
"base_url": "https://api.example.com",
32+
"bearer_token": "your_token_here"
33+
};
34+
```
35+
36+
### Example: Connect to HubSpot
37+
38+
```sql
39+
CREATE DATABASE my_hubspot
40+
WITH ENGINE = 'rest_api',
41+
PARAMETERS = {
42+
"base_url": "https://api.hubapi.com",
43+
"bearer_token": "pat-eu1-..."
44+
};
45+
```
46+
47+
### Example: Connect with default headers and a custom test path
48+
49+
```sql
50+
CREATE DATABASE my_internal_api
51+
WITH ENGINE = 'rest_api',
52+
PARAMETERS = {
53+
"base_url": "https://internal.example.com/api/v2",
54+
"bearer_token": "sk-...",
55+
"default_headers": {"Accept": "application/json"},
56+
"test_path": "/health"
57+
};
58+
```
59+
60+
### Example: Multiple allowed hosts
61+
62+
```sql
63+
CREATE DATABASE my_multi_region_api
64+
WITH ENGINE = 'rest_api',
65+
PARAMETERS = {
66+
"base_url": "https://api.example.com",
67+
"bearer_token": "your_token",
68+
"allowed_hosts": ["api.example.com", "api.eu.example.com"]
69+
};
70+
```
71+
72+
## Usage
73+
74+
This handler is **passthrough-only** — it does not expose SQL tables. All interaction is through the REST passthrough endpoint.
75+
76+
### Sending requests
77+
78+
Forward HTTP requests to the upstream API:
79+
80+
```
81+
POST /api/integrations/my_api/passthrough
82+
```
83+
84+
```json
85+
{
86+
"method": "GET",
87+
"path": "/v1/users",
88+
"query": {"limit": "10"},
89+
"headers": {"Accept": "application/json"}
90+
}
91+
```
92+
93+
The response wraps the upstream HTTP response:
94+
95+
```json
96+
{
97+
"status_code": 200,
98+
"headers": {"content-type": "application/json"},
99+
"body": {"results": [...]},
100+
"content_type": "application/json"
101+
}
102+
```
103+
104+
Supported HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.
105+
106+
### Testing the connection
107+
108+
Verify that the base URL, token, and host allowlist are configured correctly:
109+
110+
```
111+
POST /api/integrations/my_api/passthrough/test
112+
```
113+
114+
A successful response:
115+
116+
```json
117+
{"ok": true, "status_code": 200, "host": "api.example.com", "latency_ms": 140}
118+
```
119+
120+
A failed response:
121+
122+
```json
123+
{"ok": false, "error_code": "auth_failed", "message": "upstream rejected credentials; base URL and allowlist look correct"}
124+
```
125+
126+
## Security
127+
128+
- Credentials are stored in MindsDB and never exposed to the caller.
129+
- Requests are restricted to hostnames in the allowlist. Private and loopback IP addresses are rejected by default.
130+
- Callers cannot override `Authorization`, `Host`, `Cookie`, or `Proxy-*` headers.
131+
- If the upstream API echoes the token in responses, it is replaced with `[REDACTED_API_KEY]`.
132+
- Request bodies are capped at 1 MB, response bodies at 10 MB.
133+
134+
<Warning>
135+
**`host 'X' is not in the datasource allowlist`**
136+
137+
The request path resolved to a different hostname than `base_url`. Add the hostname to `allowed_hosts`, or use `["*"]` to disable host containment (not recommended for production).
138+
</Warning>
139+
140+
<Warning>
141+
**`upstream rejected credentials (401/403)`**
142+
143+
The token is invalid, expired, or missing required scopes. Verify the token with the upstream API provider.
144+
</Warning>
145+
146+
<Info>
147+
For more information about available actions and development plans, visit [this page](https://github.com/mindsdb/mindsdb/blob/main/mindsdb/integrations/handlers/rest_api_handler/README.md).
148+
</Info>

mindsdb/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
__title__ = "MindsDB"
22
__package_name__ = "mindsdb"
3-
__version__ = "26.1.0"
3+
__version__ = "26.2.0"
44
__description__ = "MindsDB's AI SQL Server enables developers to build AI tools that need access to real-time data to perform their tasks"
55
__email__ = "jorge@mindsdb.com"
66
__author__ = "MindsDB Inc"

mindsdb/api/http/initialize.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from mindsdb.api.http.namespaces.default import ns_conf as default_ns, check_session_auth
2929
from mindsdb.api.http.namespaces.file import ns_conf as file_ns
3030
from mindsdb.api.http.namespaces.handlers import ns_conf as handlers_ns
31+
from mindsdb.api.http.namespaces.integrations import ns_conf as integrations_ns
3132
from mindsdb.api.http.namespaces.knowledge_bases import ns_conf as knowledge_bases_ns
3233
from mindsdb.api.http.namespaces.models import ns_conf as models_ns
3334
from mindsdb.api.http.namespaces.projects import ns_conf as projects_ns
@@ -280,6 +281,7 @@ def root_index(path):
280281
agents_ns,
281282
jobs_ns,
282283
knowledge_bases_ns,
284+
integrations_ns,
283285
]
284286

285287
for ns in protected_namespaces:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from flask_restx import Namespace
2+
3+
ns_conf = Namespace("integrations", description="API for integration-level operations (passthrough, capabilities)")
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from http import HTTPStatus
2+
3+
from flask import request
4+
from flask_restx import Resource
5+
6+
from mindsdb.api.http.utils import http_error
7+
from mindsdb.api.http.namespaces.configs.integrations import ns_conf
8+
from mindsdb.api.mysql.mysql_proxy.classes.fake_mysql_proxy import FakeMysqlProxy
9+
from mindsdb.integrations.libs.passthrough import PassthroughProtocol
10+
from mindsdb.integrations.libs.passthrough_types import (
11+
ALLOWED_METHODS,
12+
FORBIDDEN_REQUEST_HEADERS,
13+
PassthroughError,
14+
PassthroughNotSupportedError,
15+
PassthroughRequest,
16+
PassthroughResponse,
17+
PassthroughValidationError,
18+
)
19+
from mindsdb.interfaces.database.integrations import integration_controller
20+
from mindsdb.metrics.metrics import api_endpoint_metrics
21+
from mindsdb.utilities import log
22+
23+
logger = log.getLogger(__name__)
24+
25+
26+
def _handler_supports_passthrough(handler_module) -> bool:
27+
handler_cls = getattr(handler_module, "Handler", None)
28+
if handler_cls is None:
29+
return False
30+
# issubclass is the right check for Protocol when classes define the
31+
# methods as real methods (not just dynamic attrs); runtime_checkable
32+
# Protocols support issubclass in that mode.
33+
try:
34+
return issubclass(handler_cls, PassthroughProtocol)
35+
except TypeError:
36+
return False
37+
38+
39+
def _get_passthrough_handler(name: str):
40+
"""Look up the datasource's handler and verify it satisfies the contract."""
41+
proxy = FakeMysqlProxy()
42+
handler = proxy.session.integration_controller.get_data_handler(name)
43+
if not isinstance(handler, PassthroughProtocol):
44+
raise PassthroughNotSupportedError(f"datasource '{name}' does not support REST passthrough")
45+
return handler
46+
47+
48+
def _parse_passthrough_request(payload: dict) -> PassthroughRequest:
49+
if not isinstance(payload, dict):
50+
raise PassthroughValidationError("request body must be a JSON object")
51+
52+
method = payload.get("method")
53+
path = payload.get("path")
54+
if not isinstance(method, str) or method.upper() not in ALLOWED_METHODS:
55+
raise PassthroughValidationError(f"'method' must be one of {sorted(ALLOWED_METHODS)}")
56+
if not isinstance(path, str) or not path.startswith("/"):
57+
raise PassthroughValidationError("'path' must be a string starting with '/'")
58+
59+
headers = payload.get("headers") or {}
60+
if not isinstance(headers, dict):
61+
raise PassthroughValidationError("'headers' must be an object")
62+
for name in headers:
63+
if not isinstance(name, str):
64+
raise PassthroughValidationError("header names must be strings")
65+
if name.lower() in FORBIDDEN_REQUEST_HEADERS or name.lower().startswith("proxy-"):
66+
raise PassthroughValidationError(f"header '{name}' is not allowed in passthrough requests")
67+
68+
query = payload.get("query") or {}
69+
if not isinstance(query, dict):
70+
raise PassthroughValidationError("'query' must be an object")
71+
72+
return PassthroughRequest(
73+
method=method.upper(),
74+
path=path,
75+
query={str(k): str(v) for k, v in query.items()},
76+
headers={str(k): str(v) for k, v in headers.items()},
77+
body=payload.get("body"),
78+
)
79+
80+
81+
def _serialize_response(resp: PassthroughResponse) -> dict:
82+
return {
83+
"status_code": resp.status_code,
84+
"headers": resp.headers,
85+
"body": resp.body,
86+
"content_type": resp.content_type,
87+
}
88+
89+
90+
def _passthrough_error_response(err: PassthroughError):
91+
return {
92+
"error_code": err.error_code,
93+
"message": str(err),
94+
}, err.http_status
95+
96+
97+
@ns_conf.route("/<name>/passthrough")
98+
@ns_conf.param("name", "Datasource name")
99+
class Passthrough(Resource):
100+
@ns_conf.doc("passthrough")
101+
@api_endpoint_metrics("POST", "/integrations/passthrough")
102+
def post(self, name: str):
103+
payload = request.json or {}
104+
try:
105+
req = _parse_passthrough_request(payload)
106+
handler = _get_passthrough_handler(name)
107+
response = handler.api_passthrough(req)
108+
except PassthroughError as e:
109+
return _passthrough_error_response(e)
110+
except Exception as e: # noqa: BLE001
111+
logger.exception("passthrough failed for datasource %s", name)
112+
return http_error(HTTPStatus.INTERNAL_SERVER_ERROR, "PassthroughError", str(e))
113+
114+
return _serialize_response(response), 200
115+
116+
117+
@ns_conf.route("/<name>/passthrough/test")
118+
@ns_conf.param("name", "Datasource name")
119+
class PassthroughTest(Resource):
120+
@ns_conf.doc("passthrough_test")
121+
@api_endpoint_metrics("POST", "/integrations/passthrough/test")
122+
def post(self, name: str):
123+
try:
124+
handler = _get_passthrough_handler(name)
125+
except PassthroughError as e:
126+
return _passthrough_error_response(e)
127+
except Exception as e: # noqa: BLE001
128+
logger.exception("passthrough test lookup failed for datasource %s", name)
129+
return http_error(HTTPStatus.INTERNAL_SERVER_ERROR, "PassthroughError", str(e))
130+
131+
result = handler.test_passthrough()
132+
return result, 200
133+
134+
135+
@ns_conf.route("/capabilities")
136+
class Capabilities(Resource):
137+
"""Return structured passthrough capabilities per handler.
138+
139+
The new ``handlers`` dict is the canonical shape callers should migrate
140+
to. The legacy flat ``bearer_passthrough`` list is still populated for
141+
backward compat — Minds can migrate on its own timeline.
142+
"""
143+
144+
@ns_conf.doc("integration_capabilities")
145+
@api_endpoint_metrics("GET", "/integrations/capabilities")
146+
def get(self):
147+
handlers: dict[str, dict] = {}
148+
bearer_engines: list[str] = []
149+
handler_modules = getattr(integration_controller, "handler_modules", {}) or {}
150+
for engine, module in handler_modules.items():
151+
try:
152+
if not _handler_supports_passthrough(module):
153+
continue
154+
handler_cls = getattr(module, "Handler", None)
155+
# Read the declarative auth mode off the handler class. Default
156+
# to "bearer" so protocol-only handlers that don't inherit the
157+
# mixin still land in a sensible bucket.
158+
auth_mode = getattr(handler_cls, "_auth_mode", "bearer")
159+
handlers[engine] = {
160+
"auth_modes": [auth_mode],
161+
"operations": ["passthrough"],
162+
}
163+
if auth_mode == "bearer":
164+
bearer_engines.append(engine)
165+
except Exception:
166+
# A broken handler module should not break the capabilities endpoint.
167+
logger.debug("skipping handler %s during capability probe", engine, exc_info=True)
168+
bearer_engines.sort()
169+
return {
170+
"handlers": handlers,
171+
# TODO: remove in v2 once Minds has migrated to the `handlers`
172+
# structured shape. Keep backward-compat for now.
173+
"bearer_passthrough": bearer_engines,
174+
}, 200

0 commit comments

Comments
 (0)