From 0d6284b26561266ca2d4e00ee03473259b56214f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 20:36:39 +0100 Subject: [PATCH 1/5] Serve frontend from root path --- .dockerignore | 3 +++ .gitignore | 2 ++ Dockerfile | 3 ++- README.md | 4 ++-- app.rb | 2 +- docs/README.md | 2 +- frontend/index.html | 5 +---- frontend/src/__tests__/App.test.tsx | 6 +++--- frontend/src/components/Bookmarklet.tsx | 3 +-- frontend/vite.config.ts | 5 +++-- spec/html2rss/web/app_spec.rb | 6 ++++++ 11 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.dockerignore b/.dockerignore index f49ba2d7..30dfa7dc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,6 @@ renovate.json spec/ tmp/ work/ +public/assets/ +public/frontend/ +public/index.html diff --git a/.gitignore b/.gitignore index cb7d66e5..c7460023 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ # Ignore frontend build output and tooling caches /frontend/dist/ +/public/assets/ +/public/index.html /frontend/node_modules/ /public/frontend /frontend/playwright-report/ diff --git a/Dockerfile b/Dockerfile index 06f91b71..2ba91241 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,6 +93,7 @@ COPY --chown=$USER:$USER Gemfile Gemfile.lock app.rb config.ru ./ COPY --chown=$USER:$USER app ./app COPY --chown=$USER:$USER config ./config COPY --chown=$USER:$USER public ./public -COPY --from=frontend-builder --chown=$USER:$USER /app/public/frontend ./public/frontend +COPY --from=frontend-builder --chown=$USER:$USER /app/public/index.html ./public/index.html +COPY --from=frontend-builder --chown=$USER:$USER /app/public/assets ./public/assets CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"] diff --git a/README.md b/README.md index f0f51620..3dc2b167 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ html2rss-web converts arbitrary websites into RSS 2.0 feeds with a slim Ruby bac ## Architecture - **Backend:** Ruby + Roda, backed by the `html2rss` gem for extraction. -- **Frontend:** Preact app built with Vite into `public/frontend`. +- **Frontend:** Preact app built with Vite into `public/`. - **Distribution:** Docker Compose by default; other deployments require manual wiring. - [Project notes](docs/README.md) @@ -101,7 +101,7 @@ The OpenAPI file is generated from Ruby request specs only. | Command | Purpose | | ----------------------- | -------------------------------------------- | | `npm run dev` | Vite dev server with hot reload (port 4001). | -| `npm run build` | Build static assets into `public/frontend`. | +| `npm run build` | Build static assets into `public/`. | | `npm run test:run` | Unit tests (Vitest). | | `npm run test:contract` | Contract tests with MSW. | diff --git a/app.rb b/app.rb index e053a56d..beebec5e 100644 --- a/app.rb +++ b/app.rb @@ -100,7 +100,7 @@ def development? = self.class.development? private def render_index_page(router) - index_path = 'public/frontend/index.html' + index_path = 'public/index.html' router.response['Content-Type'] = 'text/html' File.exist?(index_path) ? File.read(index_path) : FALLBACK_HTML end diff --git a/docs/README.md b/docs/README.md index cc5a75ef..0ec95fb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ The only other file that belongs in `docs/` is the generated OpenAPI contract at ## System Snapshot - Backend: Ruby + Roda under the `Html2rss::Web` namespace. -- Frontend: Preact + Vite, built into `public/frontend`. +- Frontend: Preact + Vite, built into `public/`. - Feed extraction: delegated to the `html2rss` gem. - Distribution: Docker Compose / Dev Container first. diff --git a/frontend/index.html b/frontend/index.html index 5b424d0c..1324d6e2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,10 +8,7 @@ name="description" content="html2rss converts fixed demo pages or operator-submitted URLs into feed endpoints." /> - + html2rss diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index dbe84e44..668d0b91 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -272,13 +272,13 @@ describe('App', () => { }); }); - it('builds a bookmarklet that returns to the current frontend entry', () => { - window.history.replaceState({}, '', 'http://localhost:3000/frontend/index.html'); + it('builds a bookmarklet that returns to the root app entry', () => { + window.history.replaceState({}, '', 'http://localhost:3000/'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' }); - expect(bookmarklet.getAttribute('href')).toContain('/frontend/index.html?url='); + expect(bookmarklet.getAttribute('href')).toContain('/?url='); expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent'); }); }); diff --git a/frontend/src/components/Bookmarklet.tsx b/frontend/src/components/Bookmarklet.tsx index 6802f5e8..a83c8f00 100644 --- a/frontend/src/components/Bookmarklet.tsx +++ b/frontend/src/components/Bookmarklet.tsx @@ -6,8 +6,7 @@ export function Bookmarklet() { appUrl.search = ''; appUrl.hash = ''; - const targetPath = appUrl.pathname.endsWith('/frontend/index.html') ? appUrl.pathname : '/'; - const targetPrefix = `${appUrl.origin}${targetPath}?url=`; + const targetPrefix = `${appUrl.origin}/?url=`; return `javascript:window.location.href=${JSON.stringify(targetPrefix)}+encodeURIComponent(window.location.href);`; })(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a49e5b55..7791f35d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import preact from '@preact/preset-vite'; export default defineConfig({ + base: '/', plugins: [preact()], server: { host: true, @@ -19,7 +20,7 @@ export default defineConfig({ exclude: ['msw/node'], }, build: { - outDir: '../public/frontend', - emptyOutDir: true, + outDir: '../public', + emptyOutDir: false, }, }); diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index 7389d751..d387fa86 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -90,6 +90,12 @@ def app = described_class expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000') end + it 'does not serve the removed legacy frontend entrypoint' do + get '/frontend/index.html' + + expect(last_response.status).to eq(404) + end + it 'serves static feed routes with caching headers' do stub_static_feed From b742e997d6bf1a1a724bf404eff589738240c4bf Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 20:43:31 +0100 Subject: [PATCH 2/5] Isolate SPA build output from public files --- .dockerignore | 4 +--- .gitignore | 3 --- Dockerfile | 3 +-- README.md | 4 ++-- app.rb | 9 +++++++-- docs/README.md | 2 +- frontend/vite.config.ts | 4 ++-- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.dockerignore b/.dockerignore index 30dfa7dc..103c3982 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,4 @@ renovate.json spec/ tmp/ work/ -public/assets/ -public/frontend/ -public/index.html +frontend/dist/ diff --git a/.gitignore b/.gitignore index c7460023..164fb7b7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,10 +38,7 @@ # Ignore frontend build output and tooling caches /frontend/dist/ -/public/assets/ -/public/index.html /frontend/node_modules/ -/public/frontend /frontend/playwright-report/ /frontend/test-results/ diff --git a/Dockerfile b/Dockerfile index 2ba91241..63f7aaf9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,7 +93,6 @@ COPY --chown=$USER:$USER Gemfile Gemfile.lock app.rb config.ru ./ COPY --chown=$USER:$USER app ./app COPY --chown=$USER:$USER config ./config COPY --chown=$USER:$USER public ./public -COPY --from=frontend-builder --chown=$USER:$USER /app/public/index.html ./public/index.html -COPY --from=frontend-builder --chown=$USER:$USER /app/public/assets ./public/assets +COPY --from=frontend-builder --chown=$USER:$USER /app/frontend/dist ./frontend/dist CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"] diff --git a/README.md b/README.md index 3dc2b167..884ac97e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ html2rss-web converts arbitrary websites into RSS 2.0 feeds with a slim Ruby bac ## Architecture - **Backend:** Ruby + Roda, backed by the `html2rss` gem for extraction. -- **Frontend:** Preact app built with Vite into `public/`. +- **Frontend:** Preact app built with Vite into `frontend/dist` and served at `/`. - **Distribution:** Docker Compose by default; other deployments require manual wiring. - [Project notes](docs/README.md) @@ -101,7 +101,7 @@ The OpenAPI file is generated from Ruby request specs only. | Command | Purpose | | ----------------------- | -------------------------------------------- | | `npm run dev` | Vite dev server with hot reload (port 4001). | -| `npm run build` | Build static assets into `public/`. | +| `npm run build` | Build static assets into `frontend/dist/`. | | `npm run test:run` | Unit tests (Vitest). | | `npm run test:contract` | Contract tests with MSW. | diff --git a/app.rb b/app.rb index beebec5e..6115126c 100644 --- a/app.rb +++ b/app.rb @@ -31,6 +31,8 @@ class App < Roda HTML + FRONTEND_DIST_PATH = 'frontend/dist' + FRONTEND_INDEX_PATH = File.join(FRONTEND_DIST_PATH, 'index.html') def self.development? = EnvironmentValidator.development? def development? = self.class.development? @@ -80,6 +82,10 @@ def development? = self.class.development? } plugin :json_parser + plugin :static, + ['/assets'], + root: FRONTEND_DIST_PATH, + headers: { 'Cache-Control' => 'public, max-age=31536000, immutable' } plugin :public plugin :head plugin :not_allowed @@ -100,9 +106,8 @@ def development? = self.class.development? private def render_index_page(router) - index_path = 'public/index.html' router.response['Content-Type'] = 'text/html' - File.exist?(index_path) ? File.read(index_path) : FALLBACK_HTML + File.exist?(FRONTEND_INDEX_PATH) ? File.read(FRONTEND_INDEX_PATH) : FALLBACK_HTML end end end diff --git a/docs/README.md b/docs/README.md index 0ec95fb3..d5dc59f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ The only other file that belongs in `docs/` is the generated OpenAPI contract at ## System Snapshot - Backend: Ruby + Roda under the `Html2rss::Web` namespace. -- Frontend: Preact + Vite, built into `public/`. +- Frontend: Preact + Vite, built into `frontend/dist` and served at `/`. - Feed extraction: delegated to the `html2rss` gem. - Distribution: Docker Compose / Dev Container first. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7791f35d..a927835f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ exclude: ['msw/node'], }, build: { - outDir: '../public', - emptyOutDir: false, + outDir: './dist', + emptyOutDir: true, }, }); From c48fa5deed9edeba68e2aa9943fa7a05e6c8f2e6 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 20:46:27 +0100 Subject: [PATCH 3/5] Trim runtime image copy set --- .dockerignore | 3 +++ Dockerfile | 1 + 2 files changed, 4 insertions(+) diff --git a/.dockerignore b/.dockerignore index 103c3982..624ce8d9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,9 @@ app.json bin/ coverage/ docs/ +!docs/api/ +!docs/api/v1/ +!docs/api/v1/openapi.yaml Dockerfile Procfile Rakefile diff --git a/Dockerfile b/Dockerfile index 63f7aaf9..3192a4b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,6 +93,7 @@ COPY --chown=$USER:$USER Gemfile Gemfile.lock app.rb config.ru ./ COPY --chown=$USER:$USER app ./app COPY --chown=$USER:$USER config ./config COPY --chown=$USER:$USER public ./public +COPY --chown=$USER:$USER docs/api/v1/openapi.yaml ./docs/api/v1/openapi.yaml COPY --from=frontend-builder --chown=$USER:$USER /app/frontend/dist ./frontend/dist CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"] From f24149738e7f2d8e7cccf74f5c18621069615c97 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 21:01:40 +0100 Subject: [PATCH 4/5] Publish OpenAPI spec at root --- .dockerignore | 3 --- Dockerfile | 1 - Makefile | 12 +++++------ README.md | 2 +- Rakefile | 6 +++--- app/web/api/v1/root_metadata.rb | 2 +- app/web/routes/api_v1/metadata_routes.rb | 21 +------------------- docs/README.md | 4 ++-- frontend/package.json | 4 ++-- frontend/src/__tests__/App.contract.test.tsx | 2 +- frontend/src/__tests__/App.test.tsx | 2 +- frontend/src/__tests__/mocks/server.ts | 2 +- frontend/src/api/generated/index.ts | 4 ++-- frontend/src/api/generated/sdk.gen.ts | 9 +-------- frontend/src/api/generated/types.gen.ts | 16 --------------- {docs/api/v1 => public}/openapi.yaml | 15 -------------- spec/html2rss/web/api/v1_spec.rb | 16 +++++---------- spec/support/openapi.rb | 2 +- 18 files changed, 28 insertions(+), 95 deletions(-) rename {docs/api/v1 => public}/openapi.yaml (97%) diff --git a/.dockerignore b/.dockerignore index 624ce8d9..103c3982 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,9 +13,6 @@ app.json bin/ coverage/ docs/ -!docs/api/ -!docs/api/v1/ -!docs/api/v1/openapi.yaml Dockerfile Procfile Rakefile diff --git a/Dockerfile b/Dockerfile index 3192a4b1..63f7aaf9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,7 +93,6 @@ COPY --chown=$USER:$USER Gemfile Gemfile.lock app.rb config.ru ./ COPY --chown=$USER:$USER app ./app COPY --chown=$USER:$USER config ./config COPY --chown=$USER:$USER public ./public -COPY --chown=$USER:$USER docs/api/v1/openapi.yaml ./docs/api/v1/openapi.yaml COPY --from=frontend-builder --chown=$USER:$USER /app/frontend/dist ./frontend/dist CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"] diff --git a/Makefile b/Makefile index 245f8dd6..fe7295f6 100644 --- a/Makefile +++ b/Makefile @@ -102,26 +102,26 @@ ready: ## Pre-commit gate (quick checks + RSpec) yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/ bundle exec rake yard:verify_public_docs -openapi: ## Regenerate docs/api/v1/openapi.yaml from request specs +openapi: ## Regenerate public/openapi.yaml from request specs bundle exec rake openapi:generate -openapi-verify: ## Regenerate OpenAPI and fail if docs/api/v1/openapi.yaml or frontend client is stale +openapi-verify: ## Regenerate OpenAPI and fail if public/openapi.yaml or frontend client is stale bundle exec rake openapi:verify $(MAKE) openapi-client-verify -openapi-client: ## Generate frontend OpenAPI client/types from docs/api/v1/openapi.yaml +openapi-client: ## Generate frontend OpenAPI client/types from public/openapi.yaml @cd frontend && npm run openapi:generate openapi-client-verify: ## Generate frontend OpenAPI client and fail if generated files are stale @cd frontend && npm run openapi:verify -openapi-lint: openapi-lint-redocly openapi-lint-spectral ## Lint docs/api/v1/openapi.yaml with Redocly and Spectral +openapi-lint: openapi-lint-redocly openapi-lint-spectral ## Lint public/openapi.yaml with Redocly and Spectral openapi-lint-redocly: ## Lint OpenAPI using Redocly recommended rules - npx --yes @redocly/cli lint --config .redocly.yaml docs/api/v1/openapi.yaml + npx --yes @redocly/cli lint --config .redocly.yaml public/openapi.yaml openapi-lint-spectral: ## Lint OpenAPI using Spectral OAS rules - npx --yes @stoplight/spectral-cli lint --ruleset .spectral.yaml docs/api/v1/openapi.yaml + npx --yes @stoplight/spectral-cli lint --ruleset .spectral.yaml public/openapi.yaml openai-lint-spectral: openapi-lint-spectral ## Alias for openapi-lint-spectral diff --git a/README.md b/README.md index 884ac97e..9bf7d39b 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ For contributors and AI agents changing backend structure, follow the rules in [ | `make lint` | Run all linters. | | `make lintfix` | Auto-fix lint warnings where possible. | | `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. | -| `make openapi` | Regenerate `docs/api/v1/openapi.yaml` from request specs. | +| `make openapi` | Regenerate `public/openapi.yaml` from request specs. | | `make openapi-verify` | Regenerate + fail if OpenAPI file is stale. | | `make clean` | Remove build artefacts. | diff --git a/Rakefile b/Rakefile index d6470448..727158dd 100644 --- a/Rakefile +++ b/Rakefile @@ -96,14 +96,14 @@ end namespace :openapi do desc 'Generate OpenAPI YAML from request specs' task :generate do - FileUtils.mkdir_p('docs/api/v1') - FileUtils.rm_f('docs/api/v1/openapi.yaml') + FileUtils.mkdir_p('public') + FileUtils.rm_f('public/openapi.yaml') sh({ 'OPENAPI' => '1' }, 'bundle exec rspec spec/html2rss/web/api/v1_spec.rb --order defined') end desc 'Verify generated OpenAPI YAML is up to date' task verify: :generate do - sh 'git diff --exit-code -- docs/api/v1/openapi.yaml' + sh 'git diff --exit-code -- public/openapi.yaml' end end diff --git a/app/web/api/v1/root_metadata.rb b/app/web/api/v1/root_metadata.rb index b1c862b5..2d936b5b 100644 --- a/app/web/api/v1/root_metadata.rb +++ b/app/web/api/v1/root_metadata.rb @@ -15,7 +15,7 @@ def build(router) api: { name: 'html2rss-web API', description: 'RESTful API for converting websites to RSS feeds', - openapi_url: "#{router.base_url}/api/v1/openapi.yaml" + openapi_url: "#{router.base_url}/openapi.yaml" }, instance: instance_payload(router) } diff --git a/app/web/routes/api_v1/metadata_routes.rb b/app/web/routes/api_v1/metadata_routes.rb index 0e87aac0..2faf7780 100644 --- a/app/web/routes/api_v1/metadata_routes.rb +++ b/app/web/routes/api_v1/metadata_routes.rb @@ -23,8 +23,7 @@ def call(router) def mount_openapi_spec(router) router.on 'openapi.yaml' do router.get do - router.response['Content-Type'] = 'application/yaml' - openapi_spec_contents + router.redirect '/openapi.yaml', 301 end end end @@ -55,29 +54,11 @@ def mount_root(router) end end - # @return [String] - def openapi_spec_path - File.expand_path('../../../../docs/api/v1/openapi.yaml', __dir__) - end - # @param router [Roda::RodaRequest] # @return [String] def render_root_metadata(router) JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router))) end - - # @return [String] - def openapi_spec_contents - return File.read(openapi_spec_path) if File.exist?(openapi_spec_path) - - <<~YAML - openapi: 3.0.3 - info: - title: html2rss-web API - version: 1.0.0 - paths: {} - YAML - end end end end diff --git a/docs/README.md b/docs/README.md index d5dc59f9..c98c8bae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ This is the only hand-written project document in `docs/`. Keep this file short, current, and operational. Do not add planning docs, migration diaries, redesign notes, or parallel architecture narratives back into this directory. -The only other file that belongs in `docs/` is the generated OpenAPI contract at [`docs/api/v1/openapi.yaml`](/Users/gil/versioned/html2rss/html2rss-web/docs/api/v1/openapi.yaml). +The only generated artifact intentionally exposed by the app is [`public/openapi.yaml`](/Users/gil/versioned/html2rss/html2rss-web/public/openapi.yaml). ## System Snapshot @@ -55,7 +55,7 @@ Frontend verification lives at `http://127.0.0.1:4001/` while the dev container ## API Contract Rules -- `docs/api/v1/openapi.yaml` is generated output, not hand-edited design prose. +- `public/openapi.yaml` is generated output, not hand-edited design prose. - Backend behavior and request specs define the contract. - Regenerate with `make openapi`. - Drift must fail with `make openapi-verify`. diff --git a/frontend/package.json b/frontend/package.json index fa55a684..114e76ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", - "openapi:generate": "openapi-ts -i ../docs/api/v1/openapi.yaml -o src/api/generated -c @hey-api/client-fetch", - "openapi:verify": "openapi-ts -i ../docs/api/v1/openapi.yaml -o src/api/generated -c @hey-api/client-fetch && git diff --exit-code -- src/api/generated", + "openapi:generate": "openapi-ts -i ../public/openapi.yaml -o src/api/generated -c @hey-api/client-fetch", + "openapi:verify": "openapi-ts -i ../public/openapi.yaml -o src/api/generated -c @hey-api/client-fetch && git diff --exit-code -- src/api/generated", "test": "vitest", "test:run": "vitest run", "test:unit": "vitest run --reporter=verbose --config vitest.unit.config.ts", diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index d694fd11..b4f08a7a 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -82,7 +82,7 @@ describe('App contract', () => { api: { name: 'html2rss-web API', description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/api/v1/openapi.yaml', + openapi_url: 'http://example.test/openapi.yaml', }, instance: { feed_creation: { diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 668d0b91..c5b44a7f 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -53,7 +53,7 @@ describe('App', () => { api: { name: 'html2rss-web API', description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/api/v1/openapi.yaml', + openapi_url: 'http://example.test/openapi.yaml', }, instance: { feed_creation: { diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 23bda60e..60098f03 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -9,7 +9,7 @@ export const server = setupServer( api: { name: 'html2rss-web API', description: 'RESTful API for converting websites to RSS feeds', - openapi_url: 'http://example.test/api/v1/openapi.yaml', + openapi_url: 'http://example.test/openapi.yaml', }, instance: { feed_creation: { diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index a81e16b8..2494ebbd 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getOpenApiSpec, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponse, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index f9f0bb7e..2d508010 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -72,13 +72,6 @@ export const getLivenessProbe = (options?: */ export const getReadinessProbe = (options?: Options) => (options?.client ?? client).get({ url: '/health/ready', ...options }); -/** - * OpenAPI specification - * - * OpenAPI specification - */ -export const getOpenApiSpec = (options?: Options) => (options?.client ?? client).get({ url: '/openapi.yaml', ...options }); - /** * List extraction strategies * diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 6a267960..9b04e235 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -245,22 +245,6 @@ export type GetReadinessProbeResponses = { export type GetReadinessProbeResponse = GetReadinessProbeResponses[keyof GetReadinessProbeResponses]; -export type GetOpenApiSpecData = { - body?: never; - path?: never; - query?: never; - url: '/openapi.yaml'; -}; - -export type GetOpenApiSpecResponses = { - /** - * serves the OpenAPI document as YAML - */ - 200: string; -}; - -export type GetOpenApiSpecResponse = GetOpenApiSpecResponses[keyof GetOpenApiSpecResponses]; - export type ListStrategiesData = { body?: never; path?: never; diff --git a/docs/api/v1/openapi.yaml b/public/openapi.yaml similarity index 97% rename from docs/api/v1/openapi.yaml rename to public/openapi.yaml index da9c939a..7e102cb3 100644 --- a/docs/api/v1/openapi.yaml +++ b/public/openapi.yaml @@ -449,21 +449,6 @@ paths: summary: Readiness probe tags: - Health - "/openapi.yaml": - get: - description: OpenAPI specification - operationId: getOpenApiSpec - responses: - '200': - content: - application/yaml: - schema: - type: string - description: serves the OpenAPI document as YAML - security: [] - summary: OpenAPI specification - tags: - - Root "/strategies": get: description: List extraction strategies diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 60260415..f7137b9f 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -114,7 +114,7 @@ def json_feed_headers_tuple expect(last_response.status).to eq(200) json = expect_success_response(last_response) - expect(json.dig('data', 'api', 'openapi_url')).to eq('http://example.org/api/v1/openapi.yaml') + expect(json.dig('data', 'api', 'openapi_url')).to eq('http://example.org/openapi.yaml') end it 'returns instance feed-creation capability', :aggregate_failures do @@ -139,18 +139,12 @@ def json_feed_headers_tuple end end - describe 'GET /api/v1/openapi.yaml', openapi: { - summary: 'OpenAPI specification', - operation_id: 'getOpenApiSpec', - tags: ['Root'], - security: [] - } do - it 'serves the OpenAPI document as YAML', :aggregate_failures do + describe 'GET /api/v1/openapi.yaml', openapi: false do + it 'redirects the versioned OpenAPI path to the public spec', :aggregate_failures do get '/api/v1/openapi.yaml' - expect(last_response.status).to eq(200) - expect(last_response.content_type).to include('application/yaml') - expect(last_response.body).to include('openapi: 3.0.3') + expect(last_response.status).to eq(301) + expect(last_response.headers['Location']).to eq('/openapi.yaml') end end diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 2483dab2..73f1cf18 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -4,7 +4,7 @@ require 'rspec/openapi' -RSpec::OpenAPI.path = 'docs/api/v1/openapi.yaml' +RSpec::OpenAPI.path = 'public/openapi.yaml' RSpec::OpenAPI.title = 'html2rss-web API' RSpec::OpenAPI.application_version = '1.0.0' RSpec::OpenAPI.enable_example = false From 02d1cede29288cc3c7c175f79a16d574762e180d Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 21:05:08 +0100 Subject: [PATCH 5/5] Enforce OpenAPI drift in CI --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b46452db..0a20343a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,15 @@ jobs: uses: actions/setup-node@v4 with: node-version-file: ".tool-versions" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies for OpenAPI client verification + run: npm ci + working-directory: frontend - - name: Verify generated OpenAPI is up to date - run: bundle exec rake openapi:verify + - name: Verify generated OpenAPI spec and client are up to date + run: make openapi-verify - name: Lint OpenAPI contract run: make openapi-lint @@ -83,9 +89,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Verify generated OpenAPI client is up to date - run: npm run openapi:verify - - name: Typecheck frontend run: npm run typecheck