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 @@ + + + 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/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 @@
+ + + + = 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 +}