File size: 3,140 Bytes
048b1e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#!/usr/bin/env node

import fs from 'node:fs/promises'
import process from 'node:process'
import YAML from 'yaml'

const FLATTEN_MARKER = 'x-flatten-allOf'
const YAML_OPTIONS = {
  indent: 2,
  lineWidth: 0,
}

function isPlainObject(value) {
  return value != null && typeof value === 'object' && !Array.isArray(value)
}

function isRefObject(value) {
  return isPlainObject(value) && typeof value.$ref === 'string'
}

function getMovableKeys(node) {
  return Object.keys(node).filter(
    (key) => key !== 'allOf' && key !== FLATTEN_MARKER && !key.startsWith('x-'),
  )
}

function moveSiblingPropertiesIntoAllOf(node) {
  const movableKeys = getMovableKeys(node)

  if (movableKeys.length === 0) {
    if (node[FLATTEN_MARKER] !== true) {
      node[FLATTEN_MARKER] = true
      return true
    }

    return false
  }

  const moved = {}
  for (const key of movableKeys) {
    moved[key] = node[key]
    delete node[key]
  }

  node.allOf.push(moved)
  node[FLATTEN_MARKER] = true

  return true
}

/**
 * Move sibling properties into an allOf branch when the schema contains a
 * referenced member. This keeps flattening hints consistent across the file.
 */
function flattenAllOf(node) {
  if (Array.isArray(node)) {
    let changed = false

    for (const item of node) {
      changed = flattenAllOf(item) || changed
    }

    return changed
  }

  if (!isPlainObject(node)) {
    return false
  }

  let changed = false
  const { allOf } = node

  const hasRefInAllOf =
    Array.isArray(allOf) && allOf.some((item) => isRefObject(item))

  if (hasRefInAllOf) {
    changed = moveSiblingPropertiesIntoAllOf(node) || changed
  }

  for (const value of Object.values(node)) {
    changed = flattenAllOf(value) || changed
  }

  return changed
}

async function pathExists(path) {
  try {
    await fs.access(path)
    return true
  } catch {
    return false
  }
}

async function validateInputFile(filePath) {
  if (!(await pathExists(filePath))) {
    throw new Error(`file not found: ${filePath}`)
  }

  const stat = await fs.stat(filePath)
  if (!stat.isFile()) {
    throw new Error(`not a file: ${filePath}`)
  }
}

async function readYamlFile(filePath) {
  const raw = await fs.readFile(filePath, 'utf8')

  try {
    return YAML.parse(raw)
  } catch (error) {
    throw new Error(`parse error: ${error.message}`)
  }
}

async function writeYamlFile(filePath, document) {
  const output = YAML.stringify(document, YAML_OPTIONS)
  const normalized = output.endsWith('\n') ? output : `${output}\n`

  await fs.writeFile(filePath, normalized, 'utf8')
}

function printUsage() {
  process.stderr.write('Usage: flatten-allof.mjs <openapi.yaml>\n')
}

async function main() {
  const [filePath] = process.argv.slice(2)

  if (!filePath) {
    printUsage()
    process.exitCode = 1
    return
  }

  try {
    await validateInputFile(filePath)

    const parsed = await readYamlFile(filePath)
    const changed = flattenAllOf(parsed)

    if (changed) {
      await writeYamlFile(filePath, parsed)
    }
  } catch (error) {
    process.stderr.write(`flatten-allof: ${error.message}\n`)
    process.exitCode = 1
  }
}

await main()