From 88f359d1a43487dafce2d8e0cec8ab012698f8ac Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Tue, 26 May 2026 11:09:33 +0200 Subject: [PATCH 1/2] feat: use private network to call API in SSR --- .github/workflows/ci.yml | 3 + README.md | 3 +- nuxt.config.ts | 4 + package.json | 2 + plugins/api.ts | 15 +- pnpm-lock.yaml | 209 ++++++++++++++++++++++++ server/routes/nuxt-api/sitemaps/urls.ts | 13 +- test/apiBase.test.ts | 33 ++++ utils/apiBase.ts | 20 +++ vitest.config.ts | 9 + 10 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 test/apiBase.test.ts create mode 100644 utils/apiBase.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2657f753..821161fcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Run typecheck run: pnpm run typecheck + - name: Run unit tests + run: pnpm run test:unit + - name: Install datagouv-components dependencies working-directory: ./datagouv-components run: pnpm install diff --git a/README.md b/README.md index a7f6ce2a5..528b99367 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ This project uses [pnpm](https://pnpm.io/) instead of npm mostly for security re ### 🔧 Environment Variables **Key variables:** -- `NUXT_PUBLIC_API_BASE`: Base URL for API calls +- `NUXT_PUBLIC_API_BASE`: Base URL for API calls (used by the browser and to build links) +- `NUXT_API_BASE_PRIVATE_NETWORK`: Private-network API base for server-side (SSR / Nitro) requests, to reach the API without going through the public internet. Falls back to `NUXT_PUBLIC_API_BASE` when unset. - `NUXT_PUBLIC_DEV_API_KEY`: API key for development environment - `NUXT_APP_COMMIT_ID`: Git commit ID (auto-generated in dev mode) - `NUXT_PUBLIC_SENTRY_DSN`: Sentry DSN for error tracking diff --git a/nuxt.config.ts b/nuxt.config.ts index 75a5d5afe..4cd268093 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -46,6 +46,10 @@ export default defineNuxtConfig({ }, runtimeConfig: { + // Private-network API base used for server-side (SSR / Nitro) requests, to reach the API + // without going through the public internet. Empty by default → falls back to `public.apiBase`. + // Override in production with NUXT_API_BASE_PRIVATE_NETWORK. + apiBasePrivateNetwork: '', crispIdentifier: '', crispKey: '', crispWebsiteId: '', diff --git a/package.json b/package.json index fc27811bd..0bbfb17b5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "preview": "nuxt preview", "postinstall": "nuxt prepare", "prepare": "husky", + "test:unit": "vitest run", "test:e2e": "playwright test", "test:e2e:update-screenshots": "playwright test tests/homepage.spec.ts tests/edito/pages-editor.spec.ts --update-snapshots", "test:e2e:ui": "playwright test --ui", @@ -118,6 +119,7 @@ "sass-embedded": "^1.99.0", "tailwindcss": "^4.2.4", "typescript": "^5.9.3", + "vitest": "^4.1.7", "vue-component-type-helpers": "^3.2.7", "vue-tsc": "^3.2.7" } diff --git a/plugins/api.ts b/plugins/api.ts index 58ea3e843..a316c89e6 100644 --- a/plugins/api.ts +++ b/plugins/api.ts @@ -8,10 +8,21 @@ export default defineNuxtPlugin({ const route = useRoute() const { t, locale } = useTranslation() + const publicApiBase = config.public.apiBase as string + // Only set on the server (private runtimeConfig keys aren't exposed to the client). + const privateNetworkApiBase = (config.apiBasePrivateNetwork as string) || '' + const makeApi = (apiOptions: { sendJson: boolean, redirectOn404: boolean }) => { return $fetch.create({ - baseURL: config.public.apiBase, - onRequest({ options }) { + baseURL: publicApiBase, + onRequest(context) { + const { options } = context + // On the server, route requests through the private-network API base (when configured) + // so SSR doesn't go over the public internet. ofetch runs onRequest before applying + // baseURL, so rewriting the (absolute) request here makes baseURL a no-op server-side. + if (import.meta.server && privateNetworkApiBase && typeof context.request === 'string') { + context.request = toServerApiUrl(context.request, publicApiBase, privateNetworkApiBase) + } if (apiOptions.sendJson) { options.headers.set('Content-Type', 'application/json') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d76ba12..b983a2494 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)) vue-component-type-helpers: specifier: ^3.2.7 version: 3.2.7 @@ -3287,6 +3290,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3572,12 +3578,18 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -3930,6 +3942,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@volar/language-core@2.4.23': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} @@ -4288,6 +4329,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -4489,6 +4534,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5289,6 +5338,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -7734,6 +7787,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -7822,6 +7878,9 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -8012,6 +8071,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyclip@0.1.12: resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} engines: {node: ^16.14.0 || >= 17.3.0} @@ -8031,6 +8093,10 @@ packages: tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -8509,6 +8575,47 @@ packages: yaml: optional: true + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -8669,6 +8776,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wkt-parser@1.5.2: resolution: {integrity: sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==} @@ -12310,6 +12422,8 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@9.36.0(jiti@2.6.1))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.36.0(jiti@2.6.1)) @@ -12542,6 +12656,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 24.12.2 @@ -12550,6 +12669,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -12959,6 +13080,47 @@ snapshots: vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) vue: 3.5.33(typescript@5.9.3) + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@volar/language-core@2.4.23': dependencies: '@volar/source-map': 2.4.23 @@ -13409,6 +13571,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.2 @@ -13597,6 +13761,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -14452,6 +14618,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend-shallow@2.0.1: @@ -17845,6 +18013,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-git@3.36.0: @@ -17920,6 +18090,8 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} @@ -18130,6 +18302,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyclip@0.1.12: {} tinyexec@1.1.1: {} @@ -18146,6 +18320,8 @@ snapshots: tinyqueue@3.0.0: {} + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -18761,6 +18937,34 @@ snapshots: terser: 5.46.2 yaml: 2.8.3 + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.2 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} vue-bundle-renderer@2.2.0: @@ -18926,6 +19130,11 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wkt-parser@1.5.2: {} word-wrap@1.2.5: {} diff --git a/server/routes/nuxt-api/sitemaps/urls.ts b/server/routes/nuxt-api/sitemaps/urls.ts index 461e9c061..d682d28ba 100644 --- a/server/routes/nuxt-api/sitemaps/urls.ts +++ b/server/routes/nuxt-api/sitemaps/urls.ts @@ -3,6 +3,7 @@ import { defineSitemapEventHandler } from '#imports' import { getQuery } from 'h3' import type { SitemapUrl } from '#sitemap/types' import type { H3Event } from 'h3' +import { toServerApiUrl } from '~/utils/apiBase' const API_V2_TYPES = ['dataset'] @@ -20,6 +21,11 @@ interface PaginatedResponse { export default defineSitemapEventHandler(async (event: H3Event) => { const config = useRuntimeConfig() + const publicApiBase = config.public.apiBase as string + // Reach the API without going through the public internet when a private-network base is configured. + const privateNetworkApiBase = (config.apiBasePrivateNetwork as string) || '' + const apiBase = privateNetworkApiBase || publicApiBase + const { type, nbSitemapSections = 1 } = getQuery(event) if (!type) return new Response(null, { status: 404 }) @@ -33,7 +39,7 @@ export default defineSitemapEventHandler(async (event: H3Event) => { // computing starting api page and max page for this sitemap section let currentPage = 1 let maxPage = 1 - await $fetch(`${config.public.apiBase}/api/1/${type}s/`, { + await $fetch(`${apiBase}/api/1/${type}s/`, { headers: { 'X-Fields': 'total' }, }).then((result) => { const total = result.total @@ -44,14 +50,15 @@ export default defineSitemapEventHandler(async (event: H3Event) => { }) const apiVersion = API_V2_TYPES.includes(String(type)) ? 2 : 1 - let nextPageUrl: string | null = config.public.apiBase + `/api/${apiVersion}/${type}s/?page_size=${pageSize}&page=${currentPage}` + let nextPageUrl: string | null = apiBase + `/api/${apiVersion}/${type}s/?page_size=${pageSize}&page=${currentPage}` const pages: SitemapUrl[] = [] do { await $fetch(nextPageUrl, { headers: { 'X-Fields': `data{${selfWebUrlKey}},next_page,page,total` }, }).then((result) => { - nextPageUrl = result.next_page + // The API returns an absolute public URL in `next_page`; keep paginating through the private base. + nextPageUrl = result.next_page ? toServerApiUrl(result.next_page, publicApiBase, privateNetworkApiBase) : null currentPage = result.page pages.push(...result.data.map((p: Record) => ({ loc: p[selfWebUrlKey], diff --git a/test/apiBase.test.ts b/test/apiBase.test.ts new file mode 100644 index 000000000..8c6436037 --- /dev/null +++ b/test/apiBase.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { toServerApiUrl } from '../utils/apiBase' + +const PUBLIC = 'https://www.data.gouv.fr' +const PRIVATE = 'http://udata-api:7000' + +describe('toServerApiUrl', () => { + it('leaves URLs untouched when no private-network base is configured', () => { + expect(toServerApiUrl('/api/1/datasets/', PUBLIC, '')).toBe('/api/1/datasets/') + expect(toServerApiUrl(`${PUBLIC}/api/1/datasets/`, PUBLIC, '')).toBe(`${PUBLIC}/api/1/datasets/`) + }) + + it('prefixes a relative path with the private-network base', () => { + expect(toServerApiUrl('/api/1/datasets/', PUBLIC, PRIVATE)).toBe(`${PRIVATE}/api/1/datasets/`) + }) + + it('prefixes a relative path that is missing its leading slash', () => { + expect(toServerApiUrl('api/1/datasets/', PUBLIC, PRIVATE)).toBe(`${PRIVATE}/api/1/datasets/`) + }) + + it('swaps the host of an absolute public API URL (e.g. paginated next_page)', () => { + expect(toServerApiUrl(`${PUBLIC}/api/1/datasets/?page=2`, PUBLIC, PRIVATE)).toBe(`${PRIVATE}/api/1/datasets/?page=2`) + }) + + it('leaves absolute URLs from other hosts untouched', () => { + expect(toServerApiUrl('https://example.com/foo', PUBLIC, PRIVATE)).toBe('https://example.com/foo') + }) + + it('normalizes trailing slashes on both bases to avoid doubling', () => { + expect(toServerApiUrl('/api/1/x', `${PUBLIC}/`, `${PRIVATE}/`)).toBe(`${PRIVATE}/api/1/x`) + expect(toServerApiUrl(`${PUBLIC}/api/1/x`, `${PUBLIC}/`, `${PRIVATE}/`)).toBe(`${PRIVATE}/api/1/x`) + }) +}) diff --git a/utils/apiBase.ts b/utils/apiBase.ts new file mode 100644 index 000000000..48efd718a --- /dev/null +++ b/utils/apiBase.ts @@ -0,0 +1,20 @@ +/** + * On the server (SSR / Nitro), rewrite an API URL to the private-network base so requests don't + * go over the public internet. Handles three cases: + * - a relative path (`/api/1/datasets/`) → prefixed with the private-network base + * - an absolute URL on the public API (e.g. the `next_page` returned by paginated endpoints) + * → host swapped for the private-network base + * - any other absolute URL (external service) → left untouched + * + * Returns the URL unchanged when no private-network base is configured, so the public base is used as-is. + */ +export function toServerApiUrl(url: string, publicBase: string, privateNetworkBase: string): string { + if (!privateNetworkBase) return url + + const pub = publicBase.replace(/\/$/, '') + const priv = privateNetworkBase.replace(/\/$/, '') + + if (url.startsWith(pub)) return priv + url.slice(pub.length) + if (/^https?:\/\//.test(url)) return url + return priv + (url.startsWith('/') ? url : `/${url}`) +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..22946cbef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + // E2E specs live in `tests/` (Playwright); `datagouv-components` has its own tooling. + exclude: ['**/node_modules/**', '**/.nuxt/**', '**/.output/**', 'tests/**', 'datagouv-components/**'], + }, +}) From e6bbd3668adad88f3a77924bfa9701bf0e608327 Mon Sep 17 00:00:00 2001 From: Thibaud Dauce Date: Wed, 27 May 2026 09:26:28 +0200 Subject: [PATCH 2/2] rename quality step --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821161fcb..1b80152a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,9 @@ jobs: include-hidden-files: true # ============================================ - # JOB 2: Quality - lint + typecheck (runs in parallel with build) + # JOB 2: Quality and tests - lint + typecheck + unit tests (runs in parallel with build) # ============================================ - quality: + quality-and-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -96,7 +96,7 @@ jobs: run: pnpm run lint # ============================================ - # JOB 3: E2E Tests (runs in parallel with quality) + # JOB 3: E2E Tests (runs in parallel with quality-and-tests) # ============================================ e2e: needs: build @@ -290,7 +290,7 @@ jobs: # JOB 4: Docker - build & push Docker image (automatic on main, manual on any branch) # ============================================ docker: - needs: [quality, e2e] + needs: [quality-and-tests, e2e] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' permissions: @@ -343,7 +343,7 @@ jobs: # JOB 5: Sentry - upload source maps (automatic on main, manual on any branch) # ============================================ sentry: - needs: [quality, e2e] + needs: [quality-and-tests, e2e] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' steps: @@ -378,7 +378,7 @@ jobs: # JOB 6: Publish datagouv-components (automatic on main, manual on any branch) # ============================================ publish-datagouv-components: - needs: [quality, e2e] + needs: [quality-and-tests, e2e] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' steps: