diff --git a/.gitignore b/.gitignore
index 05a35da8e..e8aee6676 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ logs
.fleet
.idea
.claude
+.tool-versions
# Local env files
.env
diff --git a/README.md b/README.md
index 39e75d6cd..4c07ec69c 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,8 @@ Start the development server on `http://localhost:3000`:
pnpm run dev
```
+You might need to add dev.local to your /etc/hosts file: `sudo sh -c 'echo "127.0.0.1 dev.local" >> /etc/hosts'`
+
### Common Commands
```bash
pnpm run dev # Start development server
diff --git a/components/OpenApi/OpenApiProperties.vue b/components/OpenApi/OpenApiProperties.vue
new file mode 100644
index 000000000..bb108f2dd
--- /dev/null
+++ b/components/OpenApi/OpenApiProperties.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+ {{ $t('Aucune réponse documentée dans le swagger.') }}
+
+
+
+ {{ endpoint.summary || endpoint.path }}
+
+
+
+
+
+
+
+ {{ $t('Impossible de charger la documentation OpenAPI.') }}
+
+
+
+
+
+
+
diff --git a/components/OpenApi/OpenApiProperty.vue b/components/OpenApi/OpenApiProperty.vue
new file mode 100644
index 000000000..8ee48aeb4
--- /dev/null
+++ b/components/OpenApi/OpenApiProperty.vue
@@ -0,0 +1,113 @@
+
+
+
+
+ {{ title || name }}
+
+
+ {{ $t('Ex : {example}', { example: String(example) }) }}
+
+
+
+
+ {{ placeholderMessage }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('Cette propriété contient 1 ou plusieurs éléments ayant les spécifications suivantes :') }}
+
+
+
+
+
+
+
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 476565720..d23d32543 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -265,6 +265,18 @@ export default defineNuxtConfig({
plugins: [toml(), tailwindcss()],
server: {
allowedHosts: ['dev.local'],
+ proxy: {
+ '/proxy/entreprise-api': {
+ target: 'https://entreprise.api.gouv.fr',
+ changeOrigin: true,
+ rewrite: path => path.replace(/^\/proxy\/entreprise-api/, ''),
+ },
+ '/proxy/particulier-api': {
+ target: 'https://particulier.api.gouv.fr',
+ changeOrigin: true,
+ rewrite: path => path.replace(/^\/proxy\/particulier-api/, ''),
+ },
+ },
warmup: {
clientFiles: [
'./pages/**/*.vue',
diff --git a/package.json b/package.json
index 057ee2536..91c5b5109 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"@vueuse/integrations": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@vueuse/router": "^14.2.1",
+ "dompurify": "^3.2.5",
"gray-matter": "^4.0.3",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.23",
@@ -94,7 +95,8 @@
"uuid": "^13.0.0",
"vue-content-loader": "^2.0.1",
"vue-router": "^5.0.4",
- "vue3-text-clamp": "^0.1.2"
+ "vue3-text-clamp": "^0.1.2",
+ "yaml": "^2.8.2"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
diff --git a/pages/dataservices/[did].vue b/pages/dataservices/[did].vue
index 2a123f254..390550dfa 100644
--- a/pages/dataservices/[did].vue
+++ b/pages/dataservices/[did].vue
@@ -191,6 +191,33 @@
+
+
+
+ {{ $t('Données renvoyées') }}
+
+
+
+
+
+
= 6'}
- yaml@2.8.2:
- resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
- engines: {node: '>= 14.6'}
- hasBin: true
-
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
@@ -16604,7 +16605,7 @@ snapshots:
picomatch: 4.0.3
string-argv: 0.3.2
tinyexec: 1.0.4
- yaml: 2.8.2
+ yaml: 2.8.3
list-item@1.1.1:
dependencies:
@@ -20266,7 +20267,7 @@ snapshots:
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.31(typescript@5.9.2)
- yaml: 2.8.2
+ yaml: 2.8.3
optionalDependencies:
'@vue/compiler-sfc': 3.5.31
@@ -20289,7 +20290,7 @@ snapshots:
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.31(typescript@5.9.3)
- yaml: 2.8.2
+ yaml: 2.8.3
optionalDependencies:
'@vue/compiler-sfc': 3.5.31
@@ -20458,8 +20459,6 @@ snapshots:
yaml@1.10.2: {}
- yaml@2.8.2: {}
-
yaml@2.8.3: {}
yargs-parser@21.1.1: {}
diff --git a/utils/openapi-bouquet.ts b/utils/openapi-bouquet.ts
new file mode 100644
index 000000000..a28019bf5
--- /dev/null
+++ b/utils/openapi-bouquet.ts
@@ -0,0 +1,93 @@
+// Bouquet-specific post-hoc transforms for extracted endpoints.
+// These operate on the output of utils/openapi-extract.ts and are only
+// applied when the dataservice title matches the Bouquet pattern.
+
+import type { EndpointProperties } from './openapi-extract'
+
+function isObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
+}
+
+// Bouquet responses wrap payloads in a top-level `data` key.
+// Object endpoints: properties = { data: { type: object, properties: {...} } }
+// Array endpoints: properties = { data: { type: array, items: { properties: { data: { properties: {...} } } } } }
+// (the double-data shape is real — confirmed against /v3/inpi/rne/.../beneficiaires_effectifs).
+function unwrapOne(properties: Record): Record {
+ const data = properties.data
+ if (!isObject(data)) return properties
+
+ if (isObject(data.properties)) {
+ return data.properties as Record
+ }
+
+ if (data.type === 'array' && isObject(data.items)) {
+ const itemsProps = (data.items as Record).properties
+ if (isObject(itemsProps)) {
+ const innerData = (itemsProps as Record).data
+ if (isObject(innerData) && isObject(innerData.properties)) {
+ return innerData.properties as Record
+ }
+ }
+ }
+
+ return properties
+}
+
+export function unwrapBouquetData(endpoints: EndpointProperties[]): EndpointProperties[] {
+ return endpoints.map(ep => ({ ...ep, properties: unwrapOne(ep.properties) }))
+}
+
+function normalize(s: string): string {
+ return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036F]/g, '').replace(/\s+/g, ' ').trim()
+}
+
+function normalizeWords(s: string): string[] {
+ return normalize(s).replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2).sort()
+}
+
+type FilterInfo = {
+ name: string
+ provider: string | null
+}
+
+function extractFilterInfo(title: string): FilterInfo | null {
+ if (!title.includes('| Bouquet')) return null
+
+ let name = title.split('| Bouquet')[0].trim()
+ if (name.startsWith('API ')) name = name.slice(4)
+
+ let provider: string | null = null
+ if (name.includes(' - ')) {
+ const parts = name.split(' - ')
+ provider = parts.pop()!.trim()
+ name = parts.join(' - ').trim()
+ }
+
+ return { name, provider }
+}
+
+function matchesEndpoint(summary: string, path: string, filter: FilterInfo): boolean {
+ const normSummary = normalize(summary)
+ const normName = normalize(filter.name)
+
+ if (normSummary.includes(normName) || normName.includes(normSummary)) return true
+
+ const nameWords = normalizeWords(filter.name)
+ const summaryWords = normalizeWords(summary)
+ if (nameWords.length >= 3 && nameWords.every(w => summaryWords.some(sw => sw.includes(w) || w.includes(sw)))) return true
+
+ if (filter.provider && normalize(path).includes(normalize(filter.provider))) {
+ const commonWords = nameWords.filter(w => w.length > 3 && summaryWords.some(sw => sw.includes(w)))
+ if (commonWords.length >= 2) return true
+ }
+
+ return false
+}
+
+export function filterEndpointsByTitle(endpoints: EndpointProperties[], title: string | undefined): EndpointProperties[] {
+ if (!title) return endpoints
+ const filter = extractFilterInfo(title)
+ if (!filter) return endpoints
+ const matched = endpoints.filter(ep => ep.summary && matchesEndpoint(ep.summary, ep.path, filter))
+ return matched.length > 0 ? matched : endpoints
+}
diff --git a/utils/openapi-extract.ts b/utils/openapi-extract.ts
new file mode 100644
index 000000000..3a1729442
--- /dev/null
+++ b/utils/openapi-extract.ts
@@ -0,0 +1,163 @@
+// Generic OpenAPI 2.0 / 3.x extractor.
+// Walks `spec.paths × HTTP methods`, extracts the 200 response schema,
+// resolves internal $ref + allOf + first variant of oneOf/anyOf,
+// and returns each endpoint's top-level properties ready to render.
+//
+// The resolver never produces user-facing French strings. When it hits
+// a circular ref, external ref, or a oneOf/anyOf variant, it emits a
+// `_placeholder` marker on the schema so the rendering component can
+// translate the message via i18n.
+
+const METHODS = ['get'] as const
+
+export type EndpointMethod = (typeof METHODS)[number]
+
+export type EndpointProperties = {
+ path: string
+ method: EndpointMethod
+ summary: string | undefined
+ properties: Record
+}
+
+export type SchemaPlaceholder = 'circular' | 'external' | 'variant'
+
+const CIRCULAR = Object.freeze({ type: 'object', _placeholder: 'circular' as const })
+const EXTERNAL_REF = Object.freeze({ type: 'object', _placeholder: 'external' as const })
+
+function isObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
+}
+
+function lookupRef(ref: string, root: Record): Record | undefined {
+ // Only internal refs: #/components/schemas/X (v3) or #/definitions/X (v2)
+ if (!ref.startsWith('#/')) return undefined
+ const parts = ref.slice(2).split('/')
+ let node: unknown = root
+ for (const part of parts) {
+ if (!isObject(node)) return undefined
+ node = node[part]
+ }
+ return isObject(node) ? node : undefined
+}
+
+function resolveSchema(
+ schema: unknown,
+ root: Record,
+ seen: Set,
+): Record {
+ if (!isObject(schema)) return {}
+
+ // $ref
+ if (typeof schema.$ref === 'string') {
+ const ref = schema.$ref
+ if (!ref.startsWith('#/')) return EXTERNAL_REF
+ if (seen.has(ref)) return CIRCULAR
+ const target = lookupRef(ref, root)
+ if (!target) return {}
+ return resolveSchema(target, root, new Set(seen).add(ref))
+ }
+
+ // allOf: merge properties from all variants
+ if (Array.isArray(schema.allOf)) {
+ const merged: Record = { type: 'object', properties: {} }
+ const mergedProps = merged.properties as Record
+ for (const sub of schema.allOf) {
+ const resolved = resolveSchema(sub, root, seen)
+ if (isObject(resolved.properties)) {
+ for (const [k, v] of Object.entries(resolved.properties)) {
+ mergedProps[k] = v
+ }
+ }
+ }
+ // Preserve description/title from the outer schema if any
+ if (typeof schema.description === 'string') merged.description = schema.description
+ if (typeof schema.title === 'string') merged.title = schema.title
+ return merged
+ }
+
+ // oneOf / anyOf: take the first variant, mark that it's one of N
+ const variants = (Array.isArray(schema.oneOf) ? schema.oneOf : Array.isArray(schema.anyOf) ? schema.anyOf : null)
+ if (variants && variants.length > 0) {
+ const first = resolveSchema(variants[0], root, seen)
+ return { ...first, _placeholder: 'variant', _variantCount: variants.length }
+ }
+
+ // Recursively resolve nested properties
+ const out: Record = { ...schema }
+ if (isObject(schema.properties)) {
+ const resolvedProps: Record = {}
+ for (const [k, v] of Object.entries(schema.properties)) {
+ resolvedProps[k] = resolveSchema(v, root, seen)
+ }
+ out.properties = resolvedProps
+ }
+ if (isObject(schema.items)) {
+ out.items = resolveSchema(schema.items, root, seen)
+ }
+ return out
+}
+
+function extractResponseSchema(
+ operation: Record,
+ isV2: boolean,
+): Record | undefined {
+ const responses = operation.responses
+ if (!isObject(responses)) return undefined
+ const ok = responses['200']
+ if (!isObject(ok)) return undefined
+
+ if (isV2) {
+ return isObject(ok.schema) ? ok.schema : undefined
+ }
+
+ const content = isObject(ok.content) ? ok.content : undefined
+ const json = content && isObject(content['application/json']) ? content['application/json'] : undefined
+ return json && isObject(json.schema) ? json.schema : undefined
+}
+
+function propertiesOf(resolved: Record): Record | undefined {
+ if (isObject(resolved.properties)) {
+ return resolved.properties as Record
+ }
+ // Top-level array: descend into items
+ if (resolved.type === 'array' && isObject(resolved.items)) {
+ const items = resolved.items as Record
+ if (isObject(items.properties)) {
+ return items.properties as Record
+ }
+ }
+ return undefined
+}
+
+export function extractEndpoints(spec: unknown): EndpointProperties[] {
+ if (!isObject(spec)) return []
+ const paths = spec.paths
+ if (!isObject(paths)) return []
+
+ const isV2 = spec.swagger === '2.0'
+ const result: EndpointProperties[] = []
+
+ for (const [path, pathItem] of Object.entries(paths)) {
+ if (!isObject(pathItem)) continue
+ for (const method of METHODS) {
+ const op = pathItem[method]
+ if (!isObject(op)) continue
+
+ const schema = extractResponseSchema(op, isV2)
+ if (!schema) continue
+
+ const resolved = resolveSchema(schema, spec as Record, new Set())
+ const properties = propertiesOf(resolved)
+ if (!properties || Object.keys(properties).length === 0) continue
+
+ result.push({
+ path,
+ method,
+ summary: typeof op.summary === 'string' ? op.summary : undefined,
+ properties,
+ })
+ }
+ }
+
+ return result
+}
diff --git a/utils/openapi-proxy.ts b/utils/openapi-proxy.ts
new file mode 100644
index 000000000..a962c59b4
--- /dev/null
+++ b/utils/openapi-proxy.ts
@@ -0,0 +1,15 @@
+const devProxyMap: Record = {
+ 'https://entreprise.api.gouv.fr': '/proxy/entreprise-api',
+ 'https://particulier.api.gouv.fr': '/proxy/particulier-api',
+}
+
+export function getProxiedUrl(url: string): string {
+ if (import.meta.dev) {
+ for (const [origin, proxy] of Object.entries(devProxyMap)) {
+ if (url.startsWith(origin)) {
+ return url.replace(origin, proxy)
+ }
+ }
+ }
+ return url
+}