Create Swagger 2.0 to OpenAPI 3.1 conversion scripts

Signed-off-by: Kévin Commaille <zecakeh@tedomum.fr>
This commit is contained in:
Kévin Commaille 2023-05-09 15:39:37 +02:00
parent dc5d95c242
commit 667c39f259
No known key found for this signature in database
GPG key ID: 29A48C1F03620416
6 changed files with 1646 additions and 0 deletions

View file

@ -0,0 +1,83 @@
"use strict";
import fs from 'node:fs/promises';
import converter from 'swagger2openapi';
import path_utils from 'node:path';
import yaml from 'yaml';
import { applyObjectFixes } from './object-fixes.mjs';
async function getStartComment(path) {
const file = await fs.open(path);
let start_comment = "";
for await (const line of file.readLines()) {
if (line.startsWith('#')) {
start_comment += line + '\n';
} else {
break;
}
};
await file.close();
return start_comment
}
async function convertDefinitionsFile(path) {
let relative_separator = path.lastIndexOf('/data/api');
let short_path = path.slice(relative_separator + 1);
console.log("%s", short_path);
// Save the comments at the start of the file to not lose them.
let start_comment = await getStartComment(path);
// Convert.
const options = await converter.convertFile(path, {
// Patch fixable errors.
patch: true,
// Keep $ref siblings.
refSiblings: 'preserve',
// Don't deduplicate requestBodies.
resolveInternal: true,
// Write OpenAPI version 3.1.0, even if it's not completely true, it'll
// be fixed later.
targetVersion: '3.1.0',
});
// Apply fixes on object.
const obj = applyObjectFixes(options.openapi);
// Serialize.
const doc = new yaml.Document(obj);
const content = yaml.stringify(doc, {
// Use "literal" blocks, like the input.
blockQuote: "literal"
});
// Save to file.
await fs.writeFile(path, start_comment + content);
}
export async function convertDefinitionsDir(path) {
console.log("Converting files in %s", path);
const files = await fs.readdir(path);
for (const file of files) {
if (file.endsWith(".yaml")) {
await convertDefinitionsFile(path_utils.join(path, file));
}
}
}
// Convert Swagger 2.0 definitions to OpenAPI 3.0.0.
export async function convertDefinitions(path) {
// We don't want to try to convert schemas in subdirectories, so we need to
// call this separately for every directory inside `data/api`.
let api_dir = path_utils.join(path, "api");
const files = await fs.readdir(api_dir);
for (const file of files) {
await convertDefinitionsDir(path_utils.join(api_dir, file));
}
}

View file

@ -0,0 +1,40 @@
"use strict";
import nopt from 'nopt';
import { convertDefinitions } from './convert-definitions.mjs';
import { applyStringFixes } from './string-fixes.mjs';
const opts = nopt({
"help": Boolean,
"data": String
}, {
"h": "--help",
"d": "--data"
});
console.log("params: {}", opts);
if (opts.help) {
console.log(
"Convert the definitions from Swagger 2.0 to OpenAPI 3.0\n" +
"Usage:\n" +
" node index.mjs -d <data_folder>"
);
process.exit(0);
}
if (!opts.data) {
console.error("No [d]ata dir specified.");
process.exit(1);
}
console.log("Converting Swagger 2.0 definitions to OpenAPI 3.0 in %s...", opts.data);
try {
await convertDefinitions(opts.data);
console.log("Applying string fixes...");
await applyStringFixes(opts.data);
console.log(" ✅ Success");
} catch (err) {
console.error(' ❌ {}', err);
process.exit(1);
}

View file

@ -0,0 +1,49 @@
"use strict";
import { URL } from 'node:url';
// Refactor `servers` field to be able to access `basePath` easily.
function refactorServers(obj) {
if (!obj.servers) {
return;
}
let server = {
url: "",
variables: {
protocol: {},
hostname: {
default: ""
},
basePath: {
default: ""
}
}
}
const url = new URL(obj.servers[0].url);
if (obj.servers.length > 1) {
// In our case several URLs always mean both http and https for the same
// host.
obj.servers.pop()
}
server.url = "{protocol}://{hostname}{basePath}"
server.variables.protocol = {
enum: ["http", "https"],
default: "https",
}
server.variables.hostname.default = url.host
server.variables.basePath.default = url.pathname
obj.servers[0] = server;
return obj;
}
// Fixes to apply to a converted schema object.
export function applyObjectFixes(obj) {
obj = refactorServers(obj);
return obj;
}

1292
scripts/openapi-convert/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
{
"name": "openapi-convert",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"nopt": "^3.0.2",
"replace": "^1.2.2",
"swagger2openapi": "^7.0.8",
"yaml": "^2.1.3"
}
}

View file

@ -0,0 +1,165 @@
"use strict";
import fs from 'node:fs/promises';
import path_utils from 'node:path';
import yaml from 'yaml';
const DEFAULT_INDENT = 2;
// Find the first child field with the given indentation in the given content.
//
// Returns the position at the end of the field's line.
function findYamlChildField(content, min_indent, field_name) {
const min_indent_string = " ".repeat(min_indent);
const field_line = min_indent_string + field_name + ":\n";
let line_start_idx = 0;
while (line_start_idx < content.length) {
const content_end = content.slice(line_start_idx);
const line_length = content_end.indexOf("\n") + 1;
const line_end_idx = line_start_idx + line_length;
const line = content_end.slice(0, line_length);
if (line == field_line) {
// This is the child we are looking for.
return line_end_idx;
}
if (!line.startsWith(min_indent_string)) {
// We changed the parent so we can't find the child anymore.
return null;
}
line_start_idx = line_end_idx;
}
// We didn't find the child.
return null;
}
// Find the end of the children with the given indentation in the YAML content.
//
// Returns the position at the end of the last child.
function findYamlChildrenEnd(content, min_indent) {
const min_indent_string = " ".repeat(min_indent);
let line_start_idx = 0;
while (line_start_idx < content.length) {
const content_end = content.slice(line_start_idx);
if (content_end.startsWith(min_indent_string)) {
const line_length = content_end.indexOf("\n") + 1;
line_start_idx += line_length;
} else {
break;
}
}
return line_start_idx;
}
// Convert and replace the YAML in the given content between start and end with JSON.
function replaceYamlWithJson(content, start, end, extra_indent) {
console.log("Processing example", start, end);
const example_yaml = content.slice(start, end);
console.log("```" + example_yaml + "```");
const example_obj = yaml.parse(example_yaml);
const example_json = JSON.stringify(example_obj, null, DEFAULT_INDENT) + "\n";
console.log("```" + example_json + "```");
// Fix the indentation.
let json_lines = example_json.split("\n");
// The first and last line don't need the extra indent.
for (let i = 1; i < json_lines.length - 1; i++) {
json_lines[i] = " ".repeat(extra_indent) + json_lines[i];
}
const indented_example_json = json_lines.join("\n");
// Put the opening bracket on the same line as the parent field.
const replace_start = start - 1;
content = content.slice(0, replace_start) + ' ' + indented_example_json + content.slice(end);
return content;
}
/// Convert the examples under the given fields in the YAML content to JSON.
function convertExamplesToJson(content, parent_field, example_field) {
const parent_field_regex_string = "( +)" + parent_field + ":\n";
const parent_field_regex = RegExp(parent_field_regex_string, 'g');
let match;
let examples = [];
while ((match = parent_field_regex.exec(content)) !== null) {
console.log("Found parent field", parent_field, match.index);
const indent_capture = match[1];
const example_field_line_indent = indent_capture.length + DEFAULT_INDENT;
const parent_field_line_end = parent_field_regex.lastIndex;
let content_end = content.slice(parent_field_line_end);
const example_field_line_end = findYamlChildField(content_end, example_field_line_indent, example_field);
if (example_field_line_end == null) {
continue;
}
const example_start = parent_field_line_end + example_field_line_end;
content_end = content.slice(example_start);
console.log("Found example at", example_start);
const example_line_min_indent = example_field_line_indent + DEFAULT_INDENT;
const example_length = findYamlChildrenEnd(content_end, example_line_min_indent);
console.log("Example length", example_length);
if (example_length > 0) {
examples.push({
start: example_start,
end: example_start + example_length,
extra_indent: example_field_line_indent,
})
}
}
for (const example of examples.reverse()) {
content = replaceYamlWithJson(content, example.start, example.end, example.extra_indent);
}
return content;
}
async function applyStringFixesFile(path) {
const relative_separator = path.lastIndexOf('/data/api');
const short_path = path.slice(relative_separator + 1);
console.log("%s", short_path);
let content = await fs.readFile(path, "utf-8");
// Fix occurrences of `*xx` status codes to `*XX`.
content = content.replaceAll("3xx:", "\"3XX\":");
content = content.replaceAll("4xx:", "\"4XX\":");
// Fix occurrences of `_ref` to `$ref`.
content = content.replaceAll("_ref:", "$ref:");
// Fix occurrences of `x-example` to `example`.
content = content.replaceAll("x-example:", "example:");
// Convert examples back to JSON.
// Response examples.
content = convertExamplesToJson(content, "response", "value");
// Schema examples.
content = convertExamplesToJson(content, "schema", "example");
await fs.writeFile(path, content);
}
// Fixes to apply to the string content of the files.
export async function applyStringFixes(path) {
const stat = await fs.lstat(path);
if (stat.isDirectory()) {
const files = await fs.readdir(path);
for (const file of files) {
await applyStringFixes(path_utils.join(path, file))
}
} else if (stat.isFile()) {
await applyStringFixesFile(path);
}
}