From 8b3a39ec57dc56d597c18eda906317f3f12cc97f Mon Sep 17 00:00:00 2001 From: Samuelfaure Date: Mon, 13 Apr 2026 14:18:22 -0300 Subject: [PATCH 1/5] Chore: .gitignore .tool-versions for asdf when using asdf as node version manager, .tool-versions is required --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 04f88fb4ae32751cf92a700bead65fa7f46f87b1 Mon Sep 17 00:00:00 2001 From: Samuelfaure Date: Mon, 13 Apr 2026 14:34:44 -0300 Subject: [PATCH 2/5] Boyscout: enhance install instructions --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 8774e2919ac4159a150ad25c159d1b2767acbb81 Mon Sep 17 00:00:00 2001 From: Samuelfaure Date: Mon, 13 Apr 2026 15:41:00 -0300 Subject: [PATCH 3/5] Feat: API Entreprise swagger import works in dev Blocked by CORS, need to make a proxy in dev environment. Adds: - nuxt.config.ts: Vite dev proxy for entreprise.api.gouv.fr and particulier.api.gouv.fr - utils/openapi-proxy.ts: client-side URL rewriter that maps external swagger URLs to the local dev proxy paths --- nuxt.config.ts | 12 ++++++++++++ utils/openapi-proxy.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 utils/openapi-proxy.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index f0cd84372..10b480318 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/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 +} From 1ab271f470867ca3465c8c10c85b615a6da19d5d Mon Sep 17 00:00:00 2001 From: Samuelfaure Date: Mon, 13 Apr 2026 16:12:26 -0300 Subject: [PATCH 4/5] feat: display OpenAPI response fields on dataservice page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a recursive OpenApiProperty component and an OpenApiProperties wrapper that fetches the dataservice's OpenAPI YAML, extracts the response schema for the matching endpoint, and renders the fields as a structured property tree. Matching uses the dataservice title against endpoint summaries (Bouquet API Entreprise/Particulier conventions). Descriptions render as sanitized HTML via DOMPurify. A loader appears while the YAML is fetched and parsed client-side. The property tree is hosted in a collapsible "Données renvoyées" banner on the dataservice page, matching the existing Swagger banner's pattern. --- components/OpenApi/OpenApiProperties.vue | 70 ++++++++++++++ components/OpenApi/OpenApiProperty.vue | 113 +++++++++++++++++++++++ package.json | 4 +- pages/dataservices/[did].vue | 35 +++++++ pnpm-lock.yaml | 19 ++-- utils/openapi-bouquet.ts | 113 +++++++++++++++++++++++ 6 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 components/OpenApi/OpenApiProperties.vue create mode 100644 components/OpenApi/OpenApiProperty.vue create mode 100644 utils/openapi-bouquet.ts 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 @@ + + + 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 @@ + + + diff --git a/package.json b/package.json index dde75202f..197c3f6bd 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 @@
+ + + + = 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'} @@ -16465,7 +16466,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: @@ -20110,7 +20111,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 @@ -20133,7 +20134,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 @@ -20302,8 +20303,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..1733a6bfa --- /dev/null +++ b/utils/openapi-bouquet.ts @@ -0,0 +1,113 @@ +// Utilities for extracting and filtering response properties from OpenAPI specs +// following the Bouquet API Entreprise / Particulier conventions: +// - Responses wrapped in a `data` key (either object or array of objects) +// - Dataservice titles shaped like "API - | Bouquet API " + +export interface EndpointProperties { + path: string + summary: string | undefined + properties: Record +} + +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() +} + +function extractDataProperties(schema: Record | undefined): Record | undefined { + const dataNode = (schema?.properties as Record)?.data as Record | undefined + if (!dataNode) return undefined + + // Array response: properties live under data.items.properties.data.properties + if (dataNode.type === 'array') { + const items = dataNode.items as Record | undefined + const innerData = (items?.properties as Record)?.data as Record | undefined + return innerData?.properties as Record | undefined + } + + return dataNode.properties as Record | undefined +} + +export function extractEndpoints(spec: unknown): EndpointProperties[] { + const paths = (spec as Record)?.paths as Record | undefined + if (!paths) return [] + + const result: EndpointProperties[] = [] + + for (const [path, methods] of Object.entries(paths)) { + const get = (methods as Record)?.get as Record | undefined + if (!get?.responses) continue + + const ok = (get.responses as Record)?.['200'] as Record | undefined + if (!ok?.content) continue + + const jsonContent = (ok.content as Record)?.['application/json'] as Record | undefined + const schema = jsonContent?.schema as Record | undefined + const properties = extractDataProperties(schema) + + if (properties && Object.keys(properties).length > 0) { + result.push({ + path, + summary: get.summary as string | undefined, + properties, + }) + } + } + + return result +} + +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) + + // Substring match (handles most cases including "[Identité] Statut..." prefixes) + if (normSummary.includes(normName) || normName.includes(normSummary)) return true + + // Word-set match (handles word order "MSA & CAF" vs "CAF & MSA" and spacing "FranceTravail" vs "France Travail") + 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 + + // Provider-in-path match (disambiguates CIBTP vs CNETP by checking the URL path) + 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 +} From dae10e5af057899bd00ba6dae00393b77eede26b Mon Sep 17 00:00:00 2001 From: Samuelfaure Date: Fri, 17 Apr 2026 15:31:23 -0300 Subject: [PATCH 5/5] feat: support any OpenAPI spec in property tree Generalises the property tree to work with any OpenAPI 2.0 or 3.x spec, not just Bouquet API Entreprise/Particulier. A new utils/openapi-extract module resolves internal $ref pointers, flattens allOf, takes the first variant of oneOf/anyOf, and handles both v2 (responses.200.schema) and v3 (responses.200.content.application/json.schema) response locations. The Bouquet-specific data-wrapper unwrap and title-based filter become post-hoc transforms applied only when the title matches the Bouquet pattern. Non-Bouquet dataservices now render all endpoints with their HTTP method prefixed (GET, POST, ...) in the heading. The property tree banner loses its redundant heading and gets a white inner background. --- utils/openapi-bouquet.ts | 88 ++++++++------------- utils/openapi-extract.ts | 163 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 54 deletions(-) create mode 100644 utils/openapi-extract.ts diff --git a/utils/openapi-bouquet.ts b/utils/openapi-bouquet.ts index 1733a6bfa..a28019bf5 100644 --- a/utils/openapi-bouquet.ts +++ b/utils/openapi-bouquet.ts @@ -1,63 +1,48 @@ -// Utilities for extracting and filtering response properties from OpenAPI specs -// following the Bouquet API Entreprise / Particulier conventions: -// - Responses wrapped in a `data` key (either object or array of objects) -// - Dataservice titles shaped like "API - | Bouquet API " - -export interface EndpointProperties { - path: string - summary: string | undefined - properties: Record -} +// 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. -function normalize(s: string): string { - return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036F]/g, '').replace(/\s+/g, ' ').trim() -} +import type { EndpointProperties } from './openapi-extract' -function normalizeWords(s: string): string[] { - return normalize(s).replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2).sort() +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) } -function extractDataProperties(schema: Record | undefined): Record | undefined { - const dataNode = (schema?.properties as Record)?.data as Record | undefined - if (!dataNode) return undefined +// 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 - // Array response: properties live under data.items.properties.data.properties - if (dataNode.type === 'array') { - const items = dataNode.items as Record | undefined - const innerData = (items?.properties as Record)?.data as Record | undefined - return innerData?.properties as Record | undefined + if (isObject(data.properties)) { + return data.properties as Record } - return dataNode.properties as Record | undefined -} - -export function extractEndpoints(spec: unknown): EndpointProperties[] { - const paths = (spec as Record)?.paths as Record | undefined - if (!paths) return [] - - const result: EndpointProperties[] = [] - - for (const [path, methods] of Object.entries(paths)) { - const get = (methods as Record)?.get as Record | undefined - if (!get?.responses) continue + 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 + } + } + } - const ok = (get.responses as Record)?.['200'] as Record | undefined - if (!ok?.content) continue + return properties +} - const jsonContent = (ok.content as Record)?.['application/json'] as Record | undefined - const schema = jsonContent?.schema as Record | undefined - const properties = extractDataProperties(schema) +export function unwrapBouquetData(endpoints: EndpointProperties[]): EndpointProperties[] { + return endpoints.map(ep => ({ ...ep, properties: unwrapOne(ep.properties) })) +} - if (properties && Object.keys(properties).length > 0) { - result.push({ - path, - summary: get.summary as string | undefined, - properties, - }) - } - } +function normalize(s: string): string { + return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036F]/g, '').replace(/\s+/g, ' ').trim() +} - return result +function normalizeWords(s: string): string[] { + return normalize(s).replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2).sort() } type FilterInfo = { @@ -85,15 +70,12 @@ function matchesEndpoint(summary: string, path: string, filter: FilterInfo): boo const normSummary = normalize(summary) const normName = normalize(filter.name) - // Substring match (handles most cases including "[Identité] Statut..." prefixes) if (normSummary.includes(normName) || normName.includes(normSummary)) return true - // Word-set match (handles word order "MSA & CAF" vs "CAF & MSA" and spacing "FranceTravail" vs "France Travail") 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 - // Provider-in-path match (disambiguates CIBTP vs CNETP by checking the URL path) 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 @@ -104,10 +86,8 @@ function matchesEndpoint(summary: string, path: string, filter: FilterInfo): boo 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 +}