|
| 1 | +import streamlit as st |
| 2 | +from databricks.sdk import WorkspaceClient |
| 3 | +from databricks.sdk.service.serving import ExternalFunctionRequestHttpMethod |
| 4 | +import json |
| 5 | +import re |
| 6 | + |
| 7 | + |
| 8 | +@st.cache_resource |
| 9 | +def get_client_obo() -> WorkspaceClient: |
| 10 | + user_token = st.context.headers["X-Forwarded-Access-Token"] |
| 11 | + if not user_token: |
| 12 | + st.error("User token is required for OBO authentication") |
| 13 | + return None |
| 14 | + |
| 15 | + if user_token: |
| 16 | + return WorkspaceClient( |
| 17 | + token=user_token, |
| 18 | + auth_type="pat" |
| 19 | + ) |
| 20 | + |
| 21 | + |
| 22 | +def init_github_mcp_connection(w: WorkspaceClient, uc_connection_name: str): |
| 23 | + """Initialize GitHub MCP and get session ID""" |
| 24 | + st.session_state.mcp_session_id = None |
| 25 | + try: |
| 26 | + |
| 27 | + init_json = { |
| 28 | + "jsonrpc": "2.0", |
| 29 | + "id": "init-1", |
| 30 | + "method": "initialize", |
| 31 | + "params": {} |
| 32 | + } |
| 33 | + |
| 34 | + response = w.serving_endpoints.http_request( |
| 35 | + conn=uc_connection_name, |
| 36 | + method=ExternalFunctionRequestHttpMethod.POST, |
| 37 | + path="/", |
| 38 | + json=init_json, |
| 39 | + ) |
| 40 | + |
| 41 | + session_id = response.headers.get("mcp-session-id") |
| 42 | + if session_id: |
| 43 | + st.session_state.mcp_session_id = session_id |
| 44 | + return session_id, None |
| 45 | + |
| 46 | + else: |
| 47 | + return None, "No session ID returned by server" |
| 48 | + |
| 49 | + except Exception as e: |
| 50 | + return None, f"Error initializing MCP: {str(e)}" |
| 51 | + |
| 52 | + |
| 53 | +def extract_login_url_from_error(error: str): |
| 54 | + """Extract login URL from error message""" |
| 55 | + |
| 56 | + url_pattern = r'https://[^\s]+/explore/connections/[^\s]+' |
| 57 | + match = re.search(url_pattern, error) |
| 58 | + |
| 59 | + if match: |
| 60 | + return match.group(0) |
| 61 | + |
| 62 | + return None |
| 63 | + |
| 64 | + |
| 65 | +def is_connection_login_error(error: str): |
| 66 | + """Check if error is a connection login error""" |
| 67 | + return "Credential for user identity" in error and "Please login first to the connection" in error |
| 68 | + |
| 69 | + |
| 70 | +HTTP_METHODS = [ |
| 71 | + {"label": "GET", "value": "GET"}, |
| 72 | + {"label": "POST", "value": "POST"} |
| 73 | +] |
| 74 | + |
| 75 | +AUTH_TYPES = [ |
| 76 | + {"label": "Bearer token", "value": "bearer_token"}, |
| 77 | + {"label": "OAuth User to Machine Per User", "value": "oauth_user_machine_per_user"}, |
| 78 | + {"label": "OAuth Machine to Machine", "value": "oauth_machine_machine"} |
| 79 | +] |
| 80 | + |
| 81 | + |
| 82 | +st.header(body="External connections", divider=True) |
| 83 | +st.subheader("Securely call external API services") |
| 84 | +st.write( |
| 85 | + "This recipe demonstrates how to use Unity Catalog-managed external HTTP connections for secure and governed access, for example, to GitHub, Jira, and Slack." |
| 86 | +) |
| 87 | + |
| 88 | +tab_app, tab_code, tab_config = st.tabs(["Try it", "Code snippet", "Requirements"]) |
| 89 | + |
| 90 | +with tab_app: |
| 91 | + st.info( |
| 92 | + "This sample will only work as intended when deployed to Databricks Apps and not when running locally. Also, you need to configure on-behalf-of-user authentication for this Databricks Apps application.", |
| 93 | + icon="ℹ️", |
| 94 | + ) |
| 95 | + |
| 96 | + connection_name = st.text_input("Connection Name", placeholder="Enter connection name...") |
| 97 | + auth_mode = st.radio( |
| 98 | + "Authentication Mode:", |
| 99 | + ["Bearer token", "OAuth User to Machine Per User", "OAuth Machine to Machine"], |
| 100 | + help="Bearer token is the user token.", |
| 101 | + ) |
| 102 | + http_method = st.selectbox("HTTP Method", options=["GET", "POST", "PUT", "DELETE", "PATCH"], ) |
| 103 | + path = st.text_input("Path", placeholder="/api/endpoint") |
| 104 | + request_type = st.selectbox("Request Type", options=["Non-MCP", "MCP"]) |
| 105 | + request_headers = st.text_area("Request headers", value='{"Content-Type": "application/json"}') |
| 106 | + request_data = st.text_area("Request data", value='{"key": "value"}') |
| 107 | + |
| 108 | + all_fields_filled = path and connection_name != "" |
| 109 | + if not all_fields_filled: |
| 110 | + st.info("Please fill in all required fields to run a query.") |
| 111 | + |
| 112 | + |
| 113 | + if st.button("Send Request"): |
| 114 | + if auth_mode == "Bearer token": |
| 115 | + w = WorkspaceClient() |
| 116 | + elif auth_mode == "OAuth User to Machine Per User": |
| 117 | + w = get_client_obo() |
| 118 | + elif auth_mode == "OAuth Machine to Machine": |
| 119 | + # TODO: Add OAuth Machine-to-Machine logic |
| 120 | + w = WorkspaceClient() |
| 121 | + |
| 122 | + if request_headers and request_headers.strip(): |
| 123 | + try: |
| 124 | + request_headers = json.loads(request_headers) |
| 125 | + except json.JSONDecodeError: |
| 126 | + st.error("❌ Invalid JSON in headers") |
| 127 | + |
| 128 | + if request_data and request_data.strip(): |
| 129 | + try: |
| 130 | + request_data = json.loads(request_data) |
| 131 | + except json.JSONDecodeError: |
| 132 | + st.error("❌ Invalid JSON data") |
| 133 | + |
| 134 | + http_method = getattr(ExternalFunctionRequestHttpMethod, http_method) |
| 135 | + |
| 136 | + if request_type == "MCP": |
| 137 | + if not st.session_state.mcp_session_id: |
| 138 | + session_id, error = init_github_mcp_connection(w) |
| 139 | + if error: |
| 140 | + if is_connection_login_error(error): |
| 141 | + login_url = extract_login_url_from_error(error) |
| 142 | + if login_url: |
| 143 | + st.warning("🔐 Connection Login Required") |
| 144 | + st.markdown("You need to authenticate with the external connection first.") |
| 145 | + st.markdown(f"[Login to Connection]({login_url})") |
| 146 | + else: |
| 147 | + st.error("❌ MCP error: {error}") |
| 148 | + else: |
| 149 | + st.error("❌ MCP initialization error: {error}") |
| 150 | + |
| 151 | + st.session_state.mcp_session_id = session_id |
| 152 | + |
| 153 | + request_headers["Mcp-Session-Id"] = st.session_state.mcp_session_id |
| 154 | + |
| 155 | + response = w.serving_endpoints.http_request( |
| 156 | + conn=connection_name, |
| 157 | + method=http_method, |
| 158 | + path=path, |
| 159 | + headers=request_headers, |
| 160 | + json=request_data, |
| 161 | + ) |
| 162 | + st.subheader("Response") |
| 163 | + st.json(response.json()) |
| 164 | + |
| 165 | + |
| 166 | + |
| 167 | +with tab_code: |
| 168 | + st.code(""" |
| 169 | +import streamlit as st |
| 170 | +from databricks import sql |
| 171 | +from databricks.sdk.core import Config |
| 172 | +
|
| 173 | +cfg = Config() |
| 174 | +
|
| 175 | +def get_user_token(): |
| 176 | + headers = st.context.headers |
| 177 | + user_token = headers["X-Forwarded-Access-Token"] |
| 178 | + return user_token |
| 179 | +
|
| 180 | +@st.cache_resource |
| 181 | +def connect_with_obo(http_path, user_token): |
| 182 | + return sql.connect( |
| 183 | + server_hostname=cfg.host, |
| 184 | + http_path=http_path, |
| 185 | + access_token=user_token |
| 186 | + ) |
| 187 | +
|
| 188 | +def execute_query(table_name, conn): |
| 189 | + with conn.cursor() as cursor: |
| 190 | + query = f"SELECT * FROM {table_name} LIMIT 10" |
| 191 | + cursor.execute(query) |
| 192 | + return cursor.fetchall_arrow().to_pandas() |
| 193 | +
|
| 194 | +user_token = get_user_token() |
| 195 | +
|
| 196 | +http_path = "/sql/1.0/warehouses/abcd1234" |
| 197 | +table_name = "samples.nyctaxi.trips" |
| 198 | +
|
| 199 | +if st.button("Run Query"): |
| 200 | + conn = connect_with_obo(http_path, user_token) |
| 201 | + |
| 202 | + df = execute_query(table_name, conn) |
| 203 | + st.dataframe(df) |
| 204 | +""") |
| 205 | + |
| 206 | +with tab_config: |
| 207 | + col1, col2, col3 = st.columns(3) |
| 208 | + |
| 209 | + with col1: |
| 210 | + st.markdown(""" |
| 211 | + **Permissions (user or app service principal)** |
| 212 | + * `SELECT` permissions on the tables being queried |
| 213 | + * `CAN USE` on the SQL warehouse |
| 214 | + """) |
| 215 | + with col2: |
| 216 | + st.markdown(""" |
| 217 | + **Databricks resources** |
| 218 | + * SQL warehouse |
| 219 | + * Unity Catalog table |
| 220 | + """) |
| 221 | + with col3: |
| 222 | + st.markdown(""" |
| 223 | + **Dependencies** |
| 224 | + * [Databricks SDK](https://pypi.org/project/databricks-sdk/) - `databricks-sdk` |
| 225 | + * [Databricks SQL Connector](https://pypi.org/project/databricks-sql-connector/) - `databricks-sql-connector` |
| 226 | + * [Streamlit](https://pypi.org/project/streamlit/) - `streamlit` |
| 227 | + """) |
0 commit comments