lutaml-openapi 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/Rakefile +16 -1
- data/frontend/dist/app.iife.js +1 -23
- data/frontend/dist/style.css +2 -1
- data/frontend/package-lock.json +1186 -2641
- data/frontend/package.json +4 -3
- data/frontend/src/App.vue +25 -11
- data/frontend/src/__tests__/i18n.test.ts +102 -0
- data/frontend/src/components/OverviewView.vue +4 -3
- data/frontend/src/components/PathsView.vue +4 -3
- data/frontend/src/components/SchemasView.vue +6 -5
- data/frontend/src/i18n/index.ts +84 -0
- data/frontend/src/i18n/messages.ts +63 -0
- data/lib/lutaml/openapi/corpus_audit.rb +4 -3
- data/lib/lutaml/openapi/document.rb +13 -12
- data/lib/lutaml/openapi/oas30.rb +4 -34
- data/lib/lutaml/openapi/version.rb +1 -1
- data/lib/lutaml/openapi.rb +5 -5
- data/script/axe_audit +109 -0
- metadata +8 -4
data/frontend/package.json
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
"vue-router": "^4.3.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@vitejs/plugin-vue": "^
|
|
19
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
20
|
+
"axe-core": "^4.14.0",
|
|
20
21
|
"jsdom": "^29.1.1",
|
|
21
22
|
"terser": "^5.46.2",
|
|
22
23
|
"typescript": "^5.3.0",
|
|
23
|
-
"vite": "^
|
|
24
|
-
"vitest": "^
|
|
24
|
+
"vite": "^8.3.5",
|
|
25
|
+
"vitest": "^5.0.5",
|
|
25
26
|
"vue-tsc": "^2.0.0"
|
|
26
27
|
}
|
|
27
28
|
}
|
data/frontend/src/App.vue
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, provide } from 'vue'
|
|
2
|
+
import { computed, ref, provide, onMounted } from 'vue'
|
|
3
3
|
import OverviewView from './components/OverviewView.vue'
|
|
4
4
|
import PathsView from './components/PathsView.vue'
|
|
5
5
|
import SchemasView from './components/SchemasView.vue'
|
|
6
|
+
import { t, tf, initLocaleFromUrl, localeLabel, nextLocale, setLocale } from './i18n'
|
|
6
7
|
|
|
7
8
|
const props = defineProps<{ openapi: Record<string, any> }>()
|
|
8
9
|
provide('openapi', props.openapi)
|
|
@@ -10,11 +11,17 @@ provide('openapi', props.openapi)
|
|
|
10
11
|
const filter = ref('')
|
|
11
12
|
const view = ref<'overview' | 'paths' | 'schemas'>('overview')
|
|
12
13
|
const views = [
|
|
13
|
-
{ id: 'overview', label: '
|
|
14
|
-
{ id: 'paths', label: '
|
|
15
|
-
{ id: 'schemas', label: '
|
|
14
|
+
{ id: 'overview', label: 'nav.overview' },
|
|
15
|
+
{ id: 'paths', label: 'nav.paths' },
|
|
16
|
+
{ id: 'schemas', label: 'nav.schemas' },
|
|
16
17
|
] as const
|
|
17
18
|
|
|
19
|
+
onMounted(() => initLocaleFromUrl())
|
|
20
|
+
|
|
21
|
+
function switchLocale() {
|
|
22
|
+
setLocale(nextLocale())
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
const info = computed(() => (props.openapi.info as any) || {})
|
|
19
26
|
const schemaCount = computed(
|
|
20
27
|
() => Object.keys(((props.openapi.components as any)?.schemas as any) || {}).length,
|
|
@@ -25,18 +32,18 @@ const pathCount = computed(() => Object.keys((props.openapi.paths as any) || {})
|
|
|
25
32
|
<template>
|
|
26
33
|
<div class="docs">
|
|
27
34
|
<header class="docs-header">
|
|
28
|
-
<h1>{{ info.title || '
|
|
35
|
+
<h1>{{ info.title || t('app.titleFallback') }}</h1>
|
|
29
36
|
<p v-if="info.version" class="version">
|
|
30
|
-
{{ info.title ? 'version' : '' }} {{ info.version }}
|
|
37
|
+
{{ info.title ? t('app.version') : '' }} {{ info.version }}
|
|
31
38
|
</p>
|
|
32
39
|
<input
|
|
33
40
|
v-model="filter"
|
|
34
41
|
class="filter"
|
|
35
42
|
type="search"
|
|
36
|
-
placeholder="
|
|
37
|
-
aria-label="
|
|
43
|
+
:placeholder="t('app.filterPlaceholder')"
|
|
44
|
+
:aria-label="t('app.filterAria')"
|
|
38
45
|
/>
|
|
39
|
-
<nav aria-label="
|
|
46
|
+
<nav :aria-label="t('nav.sections')">
|
|
40
47
|
<button
|
|
41
48
|
v-for="v in views"
|
|
42
49
|
:key="v.id"
|
|
@@ -44,9 +51,16 @@ const pathCount = computed(() => Object.keys((props.openapi.paths as any) || {})
|
|
|
44
51
|
:class="{ active: view === v.id }"
|
|
45
52
|
@click="view = v.id"
|
|
46
53
|
>
|
|
47
|
-
{{ v.label }}
|
|
54
|
+
{{ t(v.label) }}
|
|
48
55
|
</button>
|
|
49
56
|
</nav>
|
|
57
|
+
<button
|
|
58
|
+
class="nav-btn locale-btn"
|
|
59
|
+
:title="t('app.switchLocale')"
|
|
60
|
+
@click="switchLocale"
|
|
61
|
+
>
|
|
62
|
+
{{ localeLabel(nextLocale()) }}
|
|
63
|
+
</button>
|
|
50
64
|
</header>
|
|
51
65
|
<main>
|
|
52
66
|
<OverviewView v-if="view === 'overview'" :filter="filter" />
|
|
@@ -54,7 +68,7 @@ const pathCount = computed(() => Object.keys((props.openapi.paths as any) || {})
|
|
|
54
68
|
<SchemasView v-else :filter="filter" />
|
|
55
69
|
</main>
|
|
56
70
|
<footer class="docs-footer">
|
|
57
|
-
<span>{{
|
|
71
|
+
<span>{{ tf('footer.stats', { schemas: schemaCount, paths: pathCount }) }}</span>
|
|
58
72
|
</footer>
|
|
59
73
|
</div>
|
|
60
74
|
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
import {
|
|
4
|
+
t,
|
|
5
|
+
tf,
|
|
6
|
+
setLocale,
|
|
7
|
+
locale,
|
|
8
|
+
locales,
|
|
9
|
+
localeLabel,
|
|
10
|
+
isLocale,
|
|
11
|
+
nextLocale,
|
|
12
|
+
directionFor,
|
|
13
|
+
applyDocumentLocale,
|
|
14
|
+
initLocaleFromUrl,
|
|
15
|
+
} from '../i18n'
|
|
16
|
+
import { messages } from '../i18n/messages'
|
|
17
|
+
|
|
18
|
+
describe('i18n', () => {
|
|
19
|
+
beforeEach(() => setLocale('en'))
|
|
20
|
+
|
|
21
|
+
it('looks up strings in the active locale', () => {
|
|
22
|
+
expect(t('nav.overview')).toBe('Overview')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('falls back to English, then to the key itself', () => {
|
|
26
|
+
expect(t('nav.paths')).toBe(messages.en['nav.paths'])
|
|
27
|
+
// @ts-expect-error — deliberate unknown key at runtime
|
|
28
|
+
expect(t('nav.doesNotExist')).toBe('nav.doesNotExist')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('interpolates parameters', () => {
|
|
32
|
+
expect(tf('footer.stats', { schemas: 3, paths: 7 }))
|
|
33
|
+
.toBe('3 schemas · 7 paths')
|
|
34
|
+
expect(tf('schemas.propertiesOf', { name: 'thing' }))
|
|
35
|
+
.toBe('Properties of thing')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('switches locales', () => {
|
|
39
|
+
setLocale('en')
|
|
40
|
+
expect(locale()).toBe('en')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('maps locales to writing directions', () => {
|
|
44
|
+
expect(directionFor('en')).toBe('ltr')
|
|
45
|
+
expect(directionFor('ar')).toBe('rtl')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('mirrors lang and dir onto the document element', () => {
|
|
49
|
+
applyDocumentLocale('en')
|
|
50
|
+
expect(document.documentElement.lang).toBe('en')
|
|
51
|
+
expect(document.documentElement.dir).toBe('ltr')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('translates every key in Arabic and mirrors RTL for it', () => {
|
|
55
|
+
setLocale('ar')
|
|
56
|
+
expect(locale()).toBe('ar')
|
|
57
|
+
expect(t('nav.schemas')).toBe('الأنماط')
|
|
58
|
+
expect(tf('footer.stats', { schemas: 2, paths: 5 }))
|
|
59
|
+
.toBe('2 أنماط · 5 مسارات')
|
|
60
|
+
expect(document.documentElement.dir).toBe('rtl')
|
|
61
|
+
expect(document.documentElement.lang).toBe('ar')
|
|
62
|
+
setLocale('en')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('falls back to English for keys a locale has not translated', () => {
|
|
66
|
+
const ar = messages.ar as Record<string, string>
|
|
67
|
+
const en = messages.en as Record<string, string>
|
|
68
|
+
const untranslated = Object.keys(en).find((k) => !(k in ar))
|
|
69
|
+
if (untranslated) {
|
|
70
|
+
setLocale('ar')
|
|
71
|
+
expect(t(untranslated as never)).toBe(en[untranslated])
|
|
72
|
+
setLocale('en')
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('exposes the locale list, labels, and the cycle', () => {
|
|
77
|
+
expect(locales()).toEqual(['en', 'ar'])
|
|
78
|
+
expect(localeLabel('ar')).toBe('العربية')
|
|
79
|
+
expect(nextLocale()).toBe('ar')
|
|
80
|
+
setLocale('ar')
|
|
81
|
+
expect(nextLocale()).toBe('en')
|
|
82
|
+
setLocale('en')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('guards isLocale against unknown values', () => {
|
|
86
|
+
expect(isLocale('ar')).toBe(true)
|
|
87
|
+
expect(isLocale('xx')).toBe(false)
|
|
88
|
+
expect(isLocale(null)).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('boots the locale from ?locale=, else keeps the default', () => {
|
|
92
|
+
initLocaleFromUrl('?locale=ar')
|
|
93
|
+
expect(locale()).toBe('ar')
|
|
94
|
+
expect(document.documentElement.dir).toBe('rtl')
|
|
95
|
+
setLocale('en')
|
|
96
|
+
initLocaleFromUrl('?locale=xx')
|
|
97
|
+
expect(locale()).toBe('en')
|
|
98
|
+
expect(document.documentElement.dir).toBe('ltr')
|
|
99
|
+
initLocaleFromUrl('')
|
|
100
|
+
expect(locale()).toBe('en')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { inject, computed } from 'vue'
|
|
3
|
+
import { t } from '../i18n'
|
|
3
4
|
|
|
4
5
|
const openapi = inject<Record<string, any>>('openapi') || {}
|
|
5
6
|
const props = defineProps<{ filter: string }>()
|
|
@@ -14,10 +15,10 @@ const shown = computed(() => {
|
|
|
14
15
|
</script>
|
|
15
16
|
|
|
16
17
|
<template>
|
|
17
|
-
<section aria-label="
|
|
18
|
-
<h2>
|
|
18
|
+
<section :aria-label="t('overview.heading')">
|
|
19
|
+
<h2>{{ t('overview.heading') }}</h2>
|
|
19
20
|
<p v-if="info.description">{{ info.description }}</p>
|
|
20
|
-
<h3 v-if="shown.length">
|
|
21
|
+
<h3 v-if="shown.length">{{ t('overview.servers') }}</h3>
|
|
21
22
|
<ul>
|
|
22
23
|
<li v-for="s in shown" :key="s.url">
|
|
23
24
|
<code>{{ s.url }}</code> — {{ s.protocol || 'https' }}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { inject, computed } from 'vue'
|
|
3
|
+
import { t } from '../i18n'
|
|
3
4
|
|
|
4
5
|
const openapi = inject<Record<string, any>>('openapi') || {}
|
|
5
6
|
const props = defineProps<{ filter: string }>()
|
|
@@ -31,8 +32,8 @@ const entries = computed(() => {
|
|
|
31
32
|
</script>
|
|
32
33
|
|
|
33
34
|
<template>
|
|
34
|
-
<section aria-label="
|
|
35
|
-
<h2>
|
|
35
|
+
<section :aria-label="t('paths.heading')">
|
|
36
|
+
<h2>{{ t('paths.heading') }}</h2>
|
|
36
37
|
<div v-for="e in entries" :key="e.path" class="path">
|
|
37
38
|
<h3><code>{{ e.path }}</code></h3>
|
|
38
39
|
<div v-for="op in e.operations" :key="op.method" class="op">
|
|
@@ -41,6 +42,6 @@ const entries = computed(() => {
|
|
|
41
42
|
<p v-if="op.description">{{ op.description }}</p>
|
|
42
43
|
</div>
|
|
43
44
|
</div>
|
|
44
|
-
<p v-if="!entries.length">
|
|
45
|
+
<p v-if="!entries.length">{{ t('paths.noMatch') }}</p>
|
|
45
46
|
</section>
|
|
46
47
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { inject, computed } from 'vue'
|
|
3
|
+
import { t, tf } from '../i18n'
|
|
3
4
|
|
|
4
5
|
const openapi = inject<Record<string, any>>('openapi') || {}
|
|
5
6
|
const props = defineProps<{ filter: string }>()
|
|
@@ -30,15 +31,15 @@ const entries = computed(() => {
|
|
|
30
31
|
</script>
|
|
31
32
|
|
|
32
33
|
<template>
|
|
33
|
-
<section aria-label="
|
|
34
|
-
<h2>
|
|
34
|
+
<section :aria-label="t('schemas.heading')">
|
|
35
|
+
<h2>{{ t('schemas.heading') }}</h2>
|
|
35
36
|
<div v-for="e in entries" :key="e.name" class="schema">
|
|
36
37
|
<h3>{{ e.name }}</h3>
|
|
37
38
|
<p v-if="e.description">{{ e.description }}</p>
|
|
38
39
|
<table v-if="e.properties.length">
|
|
39
|
-
<caption>
|
|
40
|
+
<caption>{{ tf('schemas.propertiesOf', { name: e.name }) }}</caption>
|
|
40
41
|
<thead>
|
|
41
|
-
<tr><th>
|
|
42
|
+
<tr><th>{{ t('schemas.name') }}</th><th>{{ t('schemas.type') }}</th><th>{{ t('schemas.description') }}</th></tr>
|
|
42
43
|
</thead>
|
|
43
44
|
<tbody>
|
|
44
45
|
<tr v-for="p in e.properties" :key="p.name">
|
|
@@ -49,6 +50,6 @@ const entries = computed(() => {
|
|
|
49
50
|
</tbody>
|
|
50
51
|
</table>
|
|
51
52
|
</div>
|
|
52
|
-
<p v-if="!entries.length">
|
|
53
|
+
<p v-if="!entries.length">{{ t('schemas.noMatch') }}</p>
|
|
53
54
|
</section>
|
|
54
55
|
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { messages, type Locale, type MessageKey } from './messages'
|
|
3
|
+
|
|
4
|
+
// The locale rides a Vue ref so every rendered string is a reactive
|
|
5
|
+
// dependency: flipping the locale re-renders exactly the components
|
|
6
|
+
// that showed translated text.
|
|
7
|
+
const activeLocale = ref<Locale>('en')
|
|
8
|
+
|
|
9
|
+
/** Native names, shown on the switch button for the TARGET locale. */
|
|
10
|
+
const LOCALE_LABELS: Record<Locale, string> = {
|
|
11
|
+
en: 'English',
|
|
12
|
+
ar: 'العربية',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Writing direction per locale — RTL locales must mirror the layout. */
|
|
16
|
+
const RTL_LOCALES: readonly string[] = ['ar', 'he', 'fa', 'ur']
|
|
17
|
+
|
|
18
|
+
export function locale(): Locale {
|
|
19
|
+
return activeLocale.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function locales(): Locale[] {
|
|
23
|
+
return Object.keys(messages) as Locale[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function localeLabel(id: Locale): string {
|
|
27
|
+
return LOCALE_LABELS[id]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isLocale(value: unknown): value is Locale {
|
|
31
|
+
return typeof value === 'string' && locales().includes(value as Locale)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The locale the switch button would move to (single-step cycle). */
|
|
35
|
+
export function nextLocale(): Locale {
|
|
36
|
+
const all = locales()
|
|
37
|
+
return all[(all.indexOf(activeLocale.value) + 1) % all.length]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function directionFor(id: Locale): 'ltr' | 'rtl' {
|
|
41
|
+
return RTL_LOCALES.includes(id) ? 'rtl' : 'ltr'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mirror <html lang/dir> so layout, fonts and scrollbars follow the locale. */
|
|
45
|
+
export function applyDocumentLocale(id: Locale = activeLocale.value): void {
|
|
46
|
+
if (typeof document === 'undefined') return
|
|
47
|
+
document.documentElement.lang = id
|
|
48
|
+
document.documentElement.dir = directionFor(id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Boot-time locale: an explicit `?locale=` wins, else the default. */
|
|
52
|
+
export function initLocaleFromUrl(
|
|
53
|
+
search: string = typeof window === 'undefined' ? '' : window.location.search,
|
|
54
|
+
): void {
|
|
55
|
+
const requested = new URLSearchParams(search).get('locale')
|
|
56
|
+
if (isLocale(requested)) {
|
|
57
|
+
setLocale(requested)
|
|
58
|
+
} else {
|
|
59
|
+
applyDocumentLocale()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setLocale(next: Locale): void {
|
|
64
|
+
activeLocale.value = next
|
|
65
|
+
applyDocumentLocale(next)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Look up a UI string in the active locale, falling back to English. */
|
|
69
|
+
export function t(key: MessageKey): string {
|
|
70
|
+
const table = messages[activeLocale.value] as Record<string, string>
|
|
71
|
+
const english = messages.en as Record<string, string>
|
|
72
|
+
return table[key] ?? english[key] ?? key
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Interpolated variant: t('search.noResultsFor', { query: 'foo' }) */
|
|
76
|
+
export function tf(
|
|
77
|
+
key: MessageKey,
|
|
78
|
+
params: Record<string, string | number>,
|
|
79
|
+
): string {
|
|
80
|
+
return Object.entries(params).reduce(
|
|
81
|
+
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
|
82
|
+
t(key),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// UI string catalog. `en` is the source of truth; additional locales add
|
|
2
|
+
// entries under their own key. Keys are flat and namespaced per component.
|
|
3
|
+
// A missing key in any locale is a type error against messages.en.
|
|
4
|
+
export const messages = {
|
|
5
|
+
en: {
|
|
6
|
+
'app.titleFallback': 'OpenAPI document',
|
|
7
|
+
'app.version': 'version',
|
|
8
|
+
'app.filterPlaceholder': 'Filter…',
|
|
9
|
+
'app.filterAria': 'Filter sections',
|
|
10
|
+
'app.switchLocale': 'Switch locale',
|
|
11
|
+
|
|
12
|
+
'nav.overview': 'Overview',
|
|
13
|
+
'nav.paths': 'Paths',
|
|
14
|
+
'nav.schemas': 'Schemas',
|
|
15
|
+
'nav.sections': 'Sections',
|
|
16
|
+
|
|
17
|
+
'overview.heading': 'Overview',
|
|
18
|
+
'overview.servers': 'Servers',
|
|
19
|
+
|
|
20
|
+
'paths.heading': 'Paths',
|
|
21
|
+
'paths.noMatch': 'No paths match the filter.',
|
|
22
|
+
|
|
23
|
+
'schemas.heading': 'Schemas',
|
|
24
|
+
'schemas.propertiesOf': 'Properties of {name}',
|
|
25
|
+
'schemas.name': 'Name',
|
|
26
|
+
'schemas.type': 'Type',
|
|
27
|
+
'schemas.description': 'Description',
|
|
28
|
+
'schemas.noMatch': 'No schemas match the filter.',
|
|
29
|
+
|
|
30
|
+
'footer.stats': '{schemas} schemas · {paths} paths',
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
ar: {
|
|
34
|
+
'app.titleFallback': 'وثيقة OpenAPI',
|
|
35
|
+
'app.version': 'الإصدار',
|
|
36
|
+
'app.filterPlaceholder': 'تصفية…',
|
|
37
|
+
'app.filterAria': 'تصفية الأقسام',
|
|
38
|
+
'app.switchLocale': 'تبديل اللغة',
|
|
39
|
+
|
|
40
|
+
'nav.overview': 'نظرة عامة',
|
|
41
|
+
'nav.paths': 'المسارات',
|
|
42
|
+
'nav.schemas': 'الأنماط',
|
|
43
|
+
'nav.sections': 'الأقسام',
|
|
44
|
+
|
|
45
|
+
'overview.heading': 'نظرة عامة',
|
|
46
|
+
'overview.servers': 'الخوادم',
|
|
47
|
+
|
|
48
|
+
'paths.heading': 'المسارات',
|
|
49
|
+
'paths.noMatch': 'لا توجد مسارات مطابقة للتصفية.',
|
|
50
|
+
|
|
51
|
+
'schemas.heading': 'الأنماط',
|
|
52
|
+
'schemas.propertiesOf': 'خصائص {name}',
|
|
53
|
+
'schemas.name': 'الاسم',
|
|
54
|
+
'schemas.type': 'النوع',
|
|
55
|
+
'schemas.description': 'الوصف',
|
|
56
|
+
'schemas.noMatch': 'لا توجد أنماط مطابقة للتصفية.',
|
|
57
|
+
|
|
58
|
+
'footer.stats': '{schemas} أنماط · {paths} مسارات',
|
|
59
|
+
},
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
export type Locale = keyof typeof messages
|
|
63
|
+
export type MessageKey = keyof typeof messages.en
|
|
@@ -73,11 +73,12 @@ module Lutaml
|
|
|
73
73
|
raw = Lutaml::Openapi.parse_raw(File.read(path))
|
|
74
74
|
# The model is JSON-keyed: YAML integer keys (unquoted response
|
|
75
75
|
# codes) stringify through the pipeline, so compare against the
|
|
76
|
-
# JSON-normalized raw.
|
|
77
|
-
#
|
|
76
|
+
# JSON-normalized raw. The wire is the losslessness contract:
|
|
77
|
+
# to_json re-emits mapped sections in dialect shape and the
|
|
78
|
+
# extensions catch-all verbatim. Hash == is deep and
|
|
78
79
|
# key-order-insensitive.
|
|
79
80
|
normalized = JSON.parse(JSON.generate(raw))
|
|
80
|
-
doc.
|
|
81
|
+
JSON.parse(doc.to_json) == normalized
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
end
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Lutaml
|
|
4
4
|
module Openapi
|
|
5
|
-
# An OpenAPI document: the root object
|
|
5
|
+
# An OpenAPI document: the root object, stored verbatim.
|
|
6
6
|
#
|
|
7
|
-
# Losslessness
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# jsonschema
|
|
12
|
-
#
|
|
7
|
+
# Losslessness is structural: every parsed section is kept exactly
|
|
8
|
+
# as the source carried it (3.0 dialects included), so both wire
|
|
9
|
+
# serializations round-trip without an emit-time transform. The
|
|
10
|
+
# typed schema view (#schemas) is where dialects converge — it
|
|
11
|
+
# materializes lutaml-jsonschema models from the 3.1-normalized
|
|
12
|
+
# shape, applying the 3.0 frontend for 3.0 sources on demand.
|
|
13
13
|
class Document < Lutaml::Model::Serializable
|
|
14
14
|
attribute :openapi, :string
|
|
15
15
|
attribute :source_dialect, :string
|
|
@@ -40,19 +40,20 @@ module Lutaml
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# components.schemas as { name => Lutaml::Jsonschema::Schema } —
|
|
43
|
-
# the typed view for diff/exports/validation.
|
|
43
|
+
# the typed view for diff/exports/validation. Schema nodes are
|
|
44
|
+
# normalized into the 3.1 shape here: this is the only place the
|
|
45
|
+
# 3.0 frontend applies.
|
|
44
46
|
def schemas
|
|
45
47
|
((components && components["schemas"]) || {}).to_h do |name, value|
|
|
46
|
-
|
|
48
|
+
shape = source_dialect == "3.0" ? Oas30.normalize(value) : value
|
|
49
|
+
[name, Lutaml::Jsonschema::Schema.from_json(JSON.generate(shape))]
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def to_hash(options = {})
|
|
51
54
|
hash = super
|
|
52
55
|
hash.delete("source_dialect") # internal: tracks the frontend used
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Oas30.denormalize(hash)
|
|
56
|
+
hash
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
end
|
data/lib/lutaml/openapi/oas30.rb
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Lutaml
|
|
4
4
|
module Openapi
|
|
5
|
-
# The OpenAPI 3.0 frontend: normalizes 3.0
|
|
6
|
-
# shape
|
|
7
|
-
#
|
|
5
|
+
# The OpenAPI 3.0 frontend: normalizes 3.0 schema nodes into the
|
|
6
|
+
# 3.1 shape as a pure hash walk. Applied only where the typed layer
|
|
7
|
+
# consumes nodes — the document hash itself is stored verbatim, so
|
|
8
|
+
# no denormalization exists.
|
|
8
9
|
module Oas30
|
|
9
10
|
module_function
|
|
10
11
|
|
|
@@ -16,14 +17,6 @@ module Lutaml
|
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def denormalize(hash)
|
|
20
|
-
walk(hash) do |node|
|
|
21
|
-
node = denormalize_nullable(node)
|
|
22
|
-
node = denormalize_exclusive(node, "exclusiveMinimum", "minimum")
|
|
23
|
-
denormalize_exclusive(node, "exclusiveMaximum", "maximum")
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
20
|
def walk(node, &)
|
|
28
21
|
case node
|
|
29
22
|
when Hash
|
|
@@ -49,20 +42,6 @@ module Lutaml
|
|
|
49
42
|
node
|
|
50
43
|
end
|
|
51
44
|
|
|
52
|
-
def denormalize_nullable(node)
|
|
53
|
-
types = node["type"]
|
|
54
|
-
return node unless types.is_a?(Array) && types.include?("null")
|
|
55
|
-
|
|
56
|
-
rest = types - ["null"]
|
|
57
|
-
if rest.empty?
|
|
58
|
-
node.delete("type") # 3.0 had only `nullable: true`, no type
|
|
59
|
-
else
|
|
60
|
-
node["type"] = rest.size == 1 ? rest.first : rest
|
|
61
|
-
end
|
|
62
|
-
node["nullable"] = true
|
|
63
|
-
node
|
|
64
|
-
end
|
|
65
|
-
|
|
66
45
|
# 3.0 boolean exclusivity rides beside the bound; 3.1 carries the
|
|
67
46
|
# bound in the exclusive* keyword itself.
|
|
68
47
|
def normalize_exclusive(node, exclusive, bound)
|
|
@@ -73,15 +52,6 @@ module Lutaml
|
|
|
73
52
|
node[exclusive] = node.delete(bound) if flag == true && node.key?(bound)
|
|
74
53
|
node
|
|
75
54
|
end
|
|
76
|
-
|
|
77
|
-
def denormalize_exclusive(node, exclusive, bound)
|
|
78
|
-
value = node[exclusive]
|
|
79
|
-
return node unless value.is_a?(Numeric)
|
|
80
|
-
|
|
81
|
-
node = node.merge(bound => value)
|
|
82
|
-
node[exclusive] = true
|
|
83
|
-
node
|
|
84
|
-
end
|
|
85
55
|
end
|
|
86
56
|
end
|
|
87
57
|
end
|
data/lib/lutaml/openapi.rb
CHANGED
|
@@ -8,10 +8,11 @@ require "json"
|
|
|
8
8
|
module Lutaml
|
|
9
9
|
# OpenAPI 3.0/3.1 as a lossless lutaml-model.
|
|
10
10
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# The document is stored verbatim — what was parsed is what the wire
|
|
12
|
+
# re-emits, so losslessness is structural rather than an emit-time
|
|
13
|
+
# transform. The 3.1 shape is the typed view: `source_dialect` tracks
|
|
14
|
+
# the frontend, and 3.0 schema nodes are normalized into JSON Schema
|
|
15
|
+
# 2020-12 by the 3.0 frontend when the typed layer materializes them.
|
|
15
16
|
# Anything unmodeled rides the extensions catch-all — documents
|
|
16
17
|
# round-trip verbatim.
|
|
17
18
|
module Openapi
|
|
@@ -26,7 +27,6 @@ module Lutaml
|
|
|
26
27
|
def self.parse(string)
|
|
27
28
|
data = parse_raw(string)
|
|
28
29
|
dialect = data["openapi"].to_s.start_with?("3.0") ? "3.0" : "3.1"
|
|
29
|
-
data = Oas30.normalize(data) if dialect == "3.0"
|
|
30
30
|
|
|
31
31
|
Document.from_json(JSON.generate(data)).tap do |doc|
|
|
32
32
|
doc.source_dialect = dialect
|