Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Proxy Feature — Required Changes to Existing Files

These changes are needed in Fern-generated or config files that aren't modified
by the proxy implementation itself.

## pyproject.toml

Add PyJWT as an optional dependency and create the `proxy` extra:

```toml
[tool.poetry.dependencies]
# ... existing deps ...
PyJWT = {version = ">=2.0.0", optional = true}

[tool.poetry.extras]
proxy = ["PyJWT"]
```

This allows users to install with:
```
pip install "deepgram-sdk[proxy]"
```

## Optional runtime dependencies (not in pyproject.toml)

These are NOT added as project dependencies — users install them directly:

- **websockets** — required for WebSocket proxying
- **fastapi** — for the FastAPI adapter
- **flask** / **flask-sock** — for the Flask adapter (flask-sock for WS)
- **django** / **channels** — for the Django adapter (channels for WS)
6 changes: 6 additions & 0 deletions src/deepgram/proxy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Deepgram Proxy — drop-in proxy middleware for web applications."""

from .engine import DeepgramProxy
from .scopes import Scope

__all__ = ["DeepgramProxy", "Scope"]
1 change: 1 addition & 0 deletions src/deepgram/proxy/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Framework adapters for the Deepgram proxy."""
131 changes: 131 additions & 0 deletions src/deepgram/proxy/adapters/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Django adapter for the Deepgram proxy."""

import asyncio
from typing import TYPE_CHECKING, Any, List

from ..errors import ProxyError

if TYPE_CHECKING:
from ..engine import DeepgramProxy


def deepgram_proxy_urls(proxy: "DeepgramProxy") -> List[Any]:
"""Create Django URL patterns that proxy requests to Deepgram.

REST views are CSRF-exempt. Optional WebSocket support requires
Django Channels (``pip install channels``).

Usage::

from django.urls import path, include
from deepgram.proxy import DeepgramProxy
from deepgram.proxy.adapters.django import deepgram_proxy_urls

proxy = DeepgramProxy(api_key="dg-xxx")
urlpatterns = [path("deepgram/", include(deepgram_proxy_urls(proxy)))]

Returns:
List of URL patterns. If Django Channels is installed, the list also
has a ``websocket_consumer`` attribute containing the ASGI consumer class.
"""
from django.http import HttpRequest, HttpResponse
from django.urls import re_path
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def proxy_rest(request: HttpRequest, path: str) -> HttpResponse:
full_path = f"/{path}"
authorization = request.headers.get("Authorization")

try:
scopes = proxy.authenticate(authorization)
proxy.authorize(full_path, scopes)
except ProxyError as exc:
return HttpResponse(exc.message, status=exc.status_code)

headers = dict(request.headers)
body = request.body
query_string = request.META.get("QUERY_STRING", "")

try:
status, resp_headers, resp_body = proxy.forward_rest_sync(
method=request.method,
path=full_path,
headers=headers,
query_string=query_string,
body=body,
)
except ProxyError as exc:
return HttpResponse(exc.message, status=exc.status_code)

response = HttpResponse(resp_body, status=status)
for k, v in resp_headers.items():
if k.lower() not in ("content-length", "content-encoding"):
response[k] = v
return response

patterns: Any = [
re_path(r"^(?P<path>.+)$", proxy_rest, name="deepgram_proxy_rest"),
]

# Optional WebSocket support via Django Channels
try:
from channels.generic.websocket import AsyncWebsocketConsumer

class DeepgramProxyConsumer(AsyncWebsocketConsumer):
"""ASGI WebSocket consumer for Deepgram proxy."""

async def connect(self) -> None:
self._path = "/" + self.scope.get("path", "").lstrip("/")
# Remove the URL prefix to get the API path
query_string = self.scope.get("query_string", b"").decode("utf-8")
subprotocol = None
for header_name, header_value in self.scope.get("headers", []):
if header_name == b"sec-websocket-protocol":
subprotocol = header_value.decode("utf-8")
break

self._subprotocol = subprotocol
self._query_string = query_string
self._message_queue: asyncio.Queue = asyncio.Queue()

await self.accept(subprotocol=subprotocol)

# Start the relay
asyncio.ensure_future(self._relay())

async def _relay(self) -> None:
async def client_receive():
msg = await self._message_queue.get()
return msg

async def client_send(msg):
if isinstance(msg, bytes):
await self.send(bytes_data=msg)
else:
await self.send(text_data=str(msg))

async def client_close(code: int, reason: str = ""):
await self.close(code=code)

await proxy.forward_websocket(
path=self._path,
query_string=self._query_string,
client_receive=client_receive,
client_send=client_send,
client_close=client_close,
subprotocol=self._subprotocol,
)

async def receive(self, text_data: str = None, bytes_data: bytes = None) -> None: # type: ignore[assignment]
await self._message_queue.put(text_data or bytes_data)

async def disconnect(self, close_code: int) -> None:
await self._message_queue.put(None)

patterns.websocket_consumer = DeepgramProxyConsumer # type: ignore[attr-defined]

except ImportError:
pass # Django Channels not installed

return patterns
98 changes: 98 additions & 0 deletions src/deepgram/proxy/adapters/fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""FastAPI adapter for the Deepgram proxy."""

from typing import TYPE_CHECKING

from ..errors import ProxyError

if TYPE_CHECKING:
from ..engine import DeepgramProxy
from fastapi import APIRouter


def create_deepgram_router(proxy: "DeepgramProxy") -> "APIRouter":
"""Create a FastAPI APIRouter that proxies requests to Deepgram.

Usage::

from fastapi import FastAPI
from deepgram.proxy import DeepgramProxy
from deepgram.proxy.adapters.fastapi import create_deepgram_router

proxy = DeepgramProxy(api_key="dg-xxx")
app = FastAPI()
app.include_router(create_deepgram_router(proxy), prefix="/deepgram")
"""
from fastapi import APIRouter, Request, Response, WebSocket, WebSocketDisconnect

router = APIRouter()

@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def proxy_rest(request: Request, path: str) -> Response:
full_path = f"/{path}"
authorization = request.headers.get("authorization")

try:
scopes = proxy.authenticate(authorization)
proxy.authorize(full_path, scopes)
except ProxyError as exc:
return Response(content=exc.message, status_code=exc.status_code)

headers = dict(request.headers)
body = await request.body()
query_string = str(request.query_params)

try:
status, resp_headers, resp_body = await proxy.forward_rest_async(
method=request.method,
path=full_path,
headers=headers,
query_string=query_string,
body=body,
)
except ProxyError as exc:
return Response(content=exc.message, status_code=exc.status_code)

return Response(content=resp_body, status_code=status, headers=resp_headers)

@router.websocket("/{path:path}")
async def proxy_websocket(ws: WebSocket, path: str) -> None:
full_path = f"/{path}"

# Extract subprotocol from Sec-WebSocket-Protocol header
subprotocol = ws.headers.get("sec-websocket-protocol")

await ws.accept(subprotocol=subprotocol)

async def client_receive():
try:
data = await ws.receive()
if data.get("type") == "websocket.disconnect":
return None
return data.get("text") or data.get("bytes")
except WebSocketDisconnect:
return None

async def client_send(msg):
if isinstance(msg, bytes):
await ws.send_bytes(msg)
else:
await ws.send_text(str(msg))

async def client_close(code: int, reason: str = ""):
try:
await ws.close(code=code, reason=reason)
except Exception:
pass

query_string = str(ws.query_params)

await proxy.forward_websocket(
path=full_path,
query_string=query_string,
client_receive=client_receive,
client_send=client_send,
client_close=client_close,
subprotocol=subprotocol,
)

return router
111 changes: 111 additions & 0 deletions src/deepgram/proxy/adapters/flask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Flask adapter for the Deepgram proxy."""

import asyncio
from typing import TYPE_CHECKING

from ..errors import ProxyError

if TYPE_CHECKING:
from ..engine import DeepgramProxy
from flask import Blueprint


def create_deepgram_blueprint(proxy: "DeepgramProxy") -> "Blueprint":
"""Create a Flask Blueprint that proxies requests to Deepgram.

REST requests use synchronous forwarding. WebSocket support requires
``flask-sock`` (``pip install flask-sock``).

Usage::

from flask import Flask
from deepgram.proxy import DeepgramProxy
from deepgram.proxy.adapters.flask import create_deepgram_blueprint

proxy = DeepgramProxy(api_key="dg-xxx")
app = Flask(__name__)
app.register_blueprint(create_deepgram_blueprint(proxy), url_prefix="/deepgram")
"""
from flask import Blueprint, Response, request

bp = Blueprint("deepgram_proxy", __name__)

@bp.route("/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
def proxy_rest(path: str) -> Response:
full_path = f"/{path}"
authorization = request.headers.get("Authorization")

try:
scopes = proxy.authenticate(authorization)
proxy.authorize(full_path, scopes)
except ProxyError as exc:
return Response(exc.message, status=exc.status_code)

headers = dict(request.headers)
body = request.get_data()
query_string = request.query_string.decode("utf-8")

try:
status, resp_headers, resp_body = proxy.forward_rest_sync(
method=request.method,
path=full_path,
headers=headers,
query_string=query_string,
body=body,
)
except ProxyError as exc:
return Response(exc.message, status=exc.status_code)

return Response(resp_body, status=status, headers=resp_headers)

# Optional WebSocket support via flask-sock
try:
from flask_sock import Sock

sock = Sock()

@sock.route("/<path:path>", bp=bp)
def proxy_websocket(ws, path: str) -> None: # type: ignore[no-untyped-def]
full_path = f"/{path}"

# flask-sock doesn't expose subprotocol headers easily;
# read from the underlying environ
subprotocol = ws.environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL")
query_string = ws.environ.get("QUERY_STRING", "")

loop = asyncio.new_event_loop()

async def client_receive():
try:
data = await loop.run_in_executor(None, ws.receive)
return data
except Exception:
return None

async def client_send(msg):
await loop.run_in_executor(None, ws.send, msg)

async def client_close(code: int, reason: str = ""):
try:
await loop.run_in_executor(None, ws.close, code, reason)
except Exception:
pass

try:
loop.run_until_complete(
proxy.forward_websocket(
path=full_path,
query_string=query_string,
client_receive=client_receive,
client_send=client_send,
client_close=client_close,
subprotocol=subprotocol,
)
)
finally:
loop.close()

except ImportError:
pass # flask-sock not installed; WS support unavailable

return bp
Loading
Loading