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.
@@ -16,12 +16,13 @@
16
16
  "vue-router": "^4.3.0"
17
17
  },
18
18
  "devDependencies": {
19
- "@vitejs/plugin-vue": "^5.0.0",
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": "^5.4.0",
24
- "vitest": "^4.1.5",
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: 'Overview' },
14
- { id: 'paths', label: 'Paths' },
15
- { id: 'schemas', label: 'Schemas' },
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 || 'OpenAPI document' }}</h1>
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="Filter…"
37
- aria-label="Filter sections"
43
+ :placeholder="t('app.filterPlaceholder')"
44
+ :aria-label="t('app.filterAria')"
38
45
  />
39
- <nav aria-label="Sections">
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>{{ schemaCount }} schemas · {{ pathCount }} paths</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="Overview">
18
- <h2>Overview</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">Servers</h3>
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="Paths">
35
- <h2>Paths</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">No paths match the filter.</p>
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="Schemas">
34
- <h2>Schemas</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>Properties of {{ e.name }}</caption>
40
+ <caption>{{ tf('schemas.propertiesOf', { name: e.name }) }}</caption>
40
41
  <thead>
41
- <tr><th>Name</th><th>Type</th><th>Description</th></tr>
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">No schemas match the filter.</p>
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. to_hash is the serialization contract:
77
- # 3.0 sources denormalize back on it. Hash == is deep and
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.to_hash == normalized
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 (3.1 shape).
5
+ # An OpenAPI document: the root object, stored verbatim.
6
6
  #
7
- # Losslessness lives on the raw hash: `components` (and every other
8
- # section) is kept verbatim, and 3.0 sources denormalize back on
9
- # serialization. The typed schema view (#schemas) materializes
10
- # lutaml-jsonschema models on demand — that view applies the
11
- # jsonschema model's own normalizations, so round-trip fidelity is
12
- # asserted on the hash, not the typed layer.
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
- [name, Lutaml::Jsonschema::Schema.from_json(JSON.generate(value))]
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
- return hash unless source_dialect == "3.0"
54
-
55
- Oas30.denormalize(hash)
56
+ hash
56
57
  end
57
58
  end
58
59
  end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Openapi
5
- # The OpenAPI 3.0 frontend: normalizes 3.0 documents into the 3.1
6
- # shape before modeling, and denormalizes back on serialization —
7
- # both directions as pure hash walks.
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Openapi
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.3"
6
6
  end
7
7
  end
@@ -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 3.1 shape is the model (its Schema objects are JSON Schema
12
- # 2020-12, carried by lutaml-jsonschema); 3.0 documents are
13
- # normalized into it by the 3.0 frontend while `source_dialect` is
14
- # kept, so serialization re-emits 3.0 semantics for 3.0 sources.
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