From 4109a1399f643dbc03daf0204a53a367c70563d3 Mon Sep 17 00:00:00 2001
From: Roni bhakta <77425964+ronibhakta1@users.noreply.github.com>
Date: Fri, 10 Jul 2026 18:32:24 +0530
Subject: [PATCH] refactor: update API versioning and restructure routes for
cleaner organization
---
README.md | 4 +++-
app/api/router.py | 8 ++++++++
app/api/{v1 => }/routes/health.py | 0
app/api/{v1 => }/routes/synthesize.py | 0
app/api/{v1 => }/routes/voices.py | 0
app/api/v1/router.py | 8 --------
app/config/settings.py | 1 -
app/main.py | 6 +++---
app/static/demo.html | 8 ++++----
docs/API.md | 8 ++++----
docs/configuration.md | 2 +-
docs/development.md | 2 +-
nginx/templates/20-speech-server.conf.template | 4 ++--
scripts/configure.sh | 1 -
tests/test_errors.py | 4 ++--
tests/test_formats.py | 2 +-
tests/test_synthesize.py | 2 +-
tests/test_voices.py | 10 +++++-----
18 files changed, 35 insertions(+), 35 deletions(-)
create mode 100644 app/api/router.py
rename app/api/{v1 => }/routes/health.py (100%)
rename app/api/{v1 => }/routes/synthesize.py (100%)
rename app/api/{v1 => }/routes/voices.py (100%)
delete mode 100644 app/api/v1/router.py
diff --git a/README.md b/README.md
index 4454586..e0e0ee6 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,11 @@ make dev-docker # start server — downloads models on first run
Server: `http://localhost:8000` · Interactive docs: `http://localhost:8000/docs` · Demo: `http://localhost:8000/demo`
+**Live demo:** [speech-server.readium.org/demo](https://speech-server.readium.org/demo)
+
**Quick test:**
```bash
-curl -s -X POST http://localhost:8000/v1/synthesize \
+curl -s -X POST http://localhost:8000/synthesize \
-H 'Content-Type: application/json' \
-d '{"text":"Hello world","voice":"urn:readium:tts:pocket:en-alba"}' \
-o /tmp/speech.mp3 && open /tmp/speech.mp3
diff --git a/app/api/router.py b/app/api/router.py
new file mode 100644
index 0000000..7e422e4
--- /dev/null
+++ b/app/api/router.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from app.api.routes.synthesize import router as synthesize_router
+from app.api.routes.voices import router as voices_router
+
+router = APIRouter()
+router.include_router(voices_router)
+router.include_router(synthesize_router)
diff --git a/app/api/v1/routes/health.py b/app/api/routes/health.py
similarity index 100%
rename from app/api/v1/routes/health.py
rename to app/api/routes/health.py
diff --git a/app/api/v1/routes/synthesize.py b/app/api/routes/synthesize.py
similarity index 100%
rename from app/api/v1/routes/synthesize.py
rename to app/api/routes/synthesize.py
diff --git a/app/api/v1/routes/voices.py b/app/api/routes/voices.py
similarity index 100%
rename from app/api/v1/routes/voices.py
rename to app/api/routes/voices.py
diff --git a/app/api/v1/router.py b/app/api/v1/router.py
deleted file mode 100644
index a1c203b..0000000
--- a/app/api/v1/router.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from fastapi import APIRouter
-
-from app.api.v1.routes.synthesize import router as synthesize_router
-from app.api.v1.routes.voices import router as voices_router
-
-v1_router = APIRouter()
-v1_router.include_router(voices_router)
-v1_router.include_router(synthesize_router)
diff --git a/app/config/settings.py b/app/config/settings.py
index a907562..204c054 100644
--- a/app/config/settings.py
+++ b/app/config/settings.py
@@ -15,7 +15,6 @@ class Settings(BaseSettings):
log_level: str = "INFO"
host: str = "0.0.0.0"
port: int = Field(default=8000, gt=0, le=65535)
- api_v1_prefix: str = "/v1"
workers: int = Field(default=1, ge=1)
domain: str = ""
diff --git a/app/main.py b/app/main.py
index a1f2169..f1a4d95 100644
--- a/app/main.py
+++ b/app/main.py
@@ -6,8 +6,8 @@
from starlette.middleware.trustedhost import TrustedHostMiddleware
from app.api.errors import register_error_handlers
-from app.api.v1.router import v1_router
-from app.api.v1.routes.health import router as health_router
+from app.api.router import router
+from app.api.routes.health import router as health_router
from app.config.settings import settings
from app.core.concurrency import init_semaphore
from app.core.registry import ProviderRegistry
@@ -73,7 +73,7 @@ def create_app() -> FastAPI:
register_error_handlers(app)
app.include_router(health_router)
- app.include_router(v1_router, prefix=settings.api_v1_prefix)
+ app.include_router(router)
@app.get("/demo", include_in_schema=False)
async def demo() -> FileResponse:
diff --git a/app/static/demo.html b/app/static/demo.html
index 47eca6b..3b96441 100644
--- a/app/static/demo.html
+++ b/app/static/demo.html
@@ -143,7 +143,7 @@
TTS API Demo
Readium Speech Server
-Talks to GET /v1/voices and POST /v1/synthesize on this same origin.
+Talks to GET /voices and POST /synthesize on this same origin.
@@ -266,7 +266,7 @@
function updateCurl() {
const body = JSON.stringify(requestBody());
curlEl.textContent =
- `curl -s -X POST ${location.origin}/v1/synthesize \\\n` +
+ `curl -s -X POST ${location.origin}/synthesize \\\n` +
` -H 'Content-Type: application/json' \\\n` +
` -d '${body}' \\\n` +
` -o speech.${boundaryEl.checked ? 'json' : formatEl.value}`;
@@ -315,7 +315,7 @@
genderFilterEl.addEventListener('change', renderVoiceOptions);
async function loadVoices() {
- const resp = await fetch('/v1/voices');
+ const resp = await fetch('/voices');
allVoices = await resp.json();
populateFilterOptions(langFilterEl, [...new Set(allVoices.map(v => v.language))].sort());
const providers = [...new Set(allVoices.map(v => v.provider))].sort();
@@ -329,7 +329,7 @@
statusPillEl.style.display = 'none';
outputEl.classList.remove('error');
outputEl.textContent = 'Loading...';
- const resp = await fetch('/v1/synthesize', {
+ const resp = await fetch('/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody()),
diff --git a/docs/API.md b/docs/API.md
index 18bc986..c58efa9 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -11,8 +11,8 @@ Base path: `/`. All bodies are `application/json` unless noted.
- [Authentication](#authentication)
- [Errors](#errors)
- [Health](#health)
-- [`GET /voices`](#get-v1voices)
-- [`POST /synthesize`](#post-v1synthesize)
+- [`GET /voices`](#get-voices)
+- [`POST /synthesize`](#post-synthesize)
- [Not implemented](#not-implemented)
---
@@ -65,7 +65,7 @@ Pydantic schema errors (`422`) additionally carry an `errors` array (raw Pydanti
---
-## `GET /v1/voices`
+## `GET /voices`
```
GET /voices
@@ -167,7 +167,7 @@ Only `text` and `voice` are required; everything else defaults as shown.
| `text` | string | — | Max `MAX_TEXT_LENGTH` chars (2000 default). Rejected if empty/whitespace after trim |
| `ssml` | bool | `false` | PocketTTS strips tags before synthesis (regex `<[^>]+>` removal) — no SSML-aware prosody |
| `language` | string \| null | `null` | Hint only; voice resolution is by `voiceURI`, not `language` |
-| `voice` | string | — | Must exactly match a `voiceURI` from `/v1/voices`. 404 if not found |
+| `voice` | string | — | Must exactly match a `voiceURI` from `/voices`. 404 if not found |
| `prev_utterance` / `next_utterance` | string \| null | `null` | Accepted, passed into `SynthesisParams`; PocketTTS ignores both |
| `publication_id` | string \| null | `null` | Accepted, currently unused (reserved for future cache scoping) |
| `boundary` | bool | `false` | `true` → JSON response with base64 audio + timing marks instead of raw binary |
diff --git a/docs/configuration.md b/docs/configuration.md
index 12a6aa5..9fa75f2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -56,4 +56,4 @@ First-time setup writes `APP_ENV=production` and prompts for `DOMAIN`. `make sta
For local dev, set `APP_ENV=development` in `.env` (`DOMAIN` becomes optional) and use `make dev-docker` — exposes `:8000` directly with no nginx.
-nginx rate-limits `/v1/synthesize` (2 req/s, burst 4) and caps connections per IP. A `503` from behind nginx is a plain nginx error page, not the app's Problem Details JSON.
+nginx rate-limits `/synthesize` (2 req/s, burst 4) and caps connections per IP. A `503` from behind nginx is a plain nginx error page, not the app's Problem Details JSON.
diff --git a/docs/development.md b/docs/development.md
index 7111340..0dee475 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -43,7 +43,7 @@ No changes to routes, synthesizer, or voice catalog. Language filtering and boun
```
Client
- └─ POST /v1/synthesize
+ └─ POST /synthesize
└─ Synthesizer
├─ validate text length + content
├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog)
diff --git a/nginx/templates/20-speech-server.conf.template b/nginx/templates/20-speech-server.conf.template
index 3363733..1494c35 100644
--- a/nginx/templates/20-speech-server.conf.template
+++ b/nginx/templates/20-speech-server.conf.template
@@ -55,7 +55,7 @@ server {
# meant a normal page load (docs UI, healthz, favicon, multiple parallel
# requests on a hard refresh) could trip nginx's built-in 503 rate-limit
# rejection well before actually threatening the synthesis backend.
- location = /v1/synthesize {
+ location = /synthesize {
limit_except POST {
deny all;
}
@@ -73,7 +73,7 @@ server {
# Headroom above what a real browser opens per origin (~6-8) — this
# is just a coarse abuse guard for general routes, not the synthesis
- # concurrency budget (that's the /v1/synthesize block above).
+ # concurrency budget (that's the /synthesize block above).
limit_conn perip 20;
include /etc/nginx/snippets/proxy-pass.conf;
diff --git a/scripts/configure.sh b/scripts/configure.sh
index e23ccca..fb3b8a7 100755
--- a/scripts/configure.sh
+++ b/scripts/configure.sh
@@ -229,7 +229,6 @@ APP_ENV=production
LOG_LEVEL=INFO
HOST=0.0.0.0
PORT=8000
-API_V1_PREFIX=/v1
WORKERS=1
DOMAIN=
diff --git a/tests/test_errors.py b/tests/test_errors.py
index cf99cae..f767cdf 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -2,7 +2,7 @@
from httpx import AsyncClient
_VOICE = "urn:readium:tts:fake:en-US-standard"
-_URL = "/v1/synthesize"
+_URL = "/synthesize"
@pytest.mark.route
@@ -21,7 +21,7 @@ async def test_text_too_long_returns_413(client: AsyncClient) -> None:
@pytest.mark.route
async def test_unknown_route_returns_about_blank_problem(client: AsyncClient) -> None:
- resp = await client.get("/v1/does-not-exist")
+ resp = await client.get("/does-not-exist")
assert resp.status_code == 404
assert resp.headers["content-type"] == "application/problem+json"
body = resp.json()
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 05016f7..2a988f7 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -2,7 +2,7 @@
from httpx import AsyncClient
_VOICE = "urn:readium:tts:fake:en-US-standard"
-_SYNTH = "/v1/synthesize"
+_SYNTH = "/synthesize"
def _body(fmt: str) -> dict: # type: ignore[type-arg]
diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py
index d6a7fe8..f15551a 100644
--- a/tests/test_synthesize.py
+++ b/tests/test_synthesize.py
@@ -5,7 +5,7 @@
_VOICE = "urn:readium:tts:fake:en-US-standard"
_VOICE_FR = "urn:readium:tts:fake:fr-FR-standard"
-_URL = "/v1/synthesize"
+_URL = "/synthesize"
_WAV = {"output": {"format": "wav"}}
diff --git a/tests/test_voices.py b/tests/test_voices.py
index 359abb2..48da6d9 100644
--- a/tests/test_voices.py
+++ b/tests/test_voices.py
@@ -4,7 +4,7 @@
@pytest.mark.route
async def test_list_voices_returns_all(client: AsyncClient) -> None:
- resp = await client.get("/v1/voices")
+ resp = await client.get("/voices")
assert resp.status_code == 200
voices = resp.json()
assert len(voices) == 2
@@ -15,7 +15,7 @@ async def test_list_voices_returns_all(client: AsyncClient) -> None:
@pytest.mark.route
async def test_list_voices_shape(client: AsyncClient) -> None:
- resp = await client.get("/v1/voices")
+ resp = await client.get("/voices")
voice = resp.json()[0]
required = (
"source",
@@ -35,7 +35,7 @@ async def test_list_voices_shape(client: AsyncClient) -> None:
@pytest.mark.route
async def test_filter_by_language(client: AsyncClient) -> None:
- resp = await client.get("/v1/voices", params={"language": "en"})
+ resp = await client.get("/voices", params={"language": "en"})
assert resp.status_code == 200
voices = resp.json()
assert len(voices) == 1
@@ -44,13 +44,13 @@ async def test_filter_by_language(client: AsyncClient) -> None:
@pytest.mark.route
async def test_filter_by_provider(client: AsyncClient) -> None:
- resp = await client.get("/v1/voices", params={"provider": "fake"})
+ resp = await client.get("/voices", params={"provider": "fake"})
assert resp.status_code == 200
assert len(resp.json()) == 2
@pytest.mark.route
async def test_filter_by_unknown_language_returns_empty(client: AsyncClient) -> None:
- resp = await client.get("/v1/voices", params={"language": "zh"})
+ resp = await client.get("/voices", params={"language": "zh"})
assert resp.status_code == 200
assert resp.json() == []