Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/codspeed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ jobs:
- name: Run benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: "instrumentation"
run: pnpm run test:bench
40 changes: 40 additions & 0 deletions packages/api-gen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,33 @@ Prepare your config file named **api-gen.config.json**:
}
```

### `split` - Experimental

Split an OpenAPI schema into multiple files, organized by tags or paths. This is useful for breaking down a large schema into smaller, more manageable parts.

The main reason for this is that the complete JSON schema can be too large and complex for API clients like Postman or Insomnia to handle, sometimes
causing performance issues or import failures due to the file size or circular references. This command helps developers to extract only the parts of
the schema they need and then import it to the API client of their choice.

Example usage:

```bash
# Display all available tags
pnpx @shopware/api-gen split <path-to-schema-file> --list tags

# Display all available paths
pnpx @shopware/api-gen split <path-to-schema-file> --list paths

# Split schema by tags and show detailed linting errors
pnpx @shopware/api-gen split <path-to-schema-file> --splitBy=tags --outputDir <output-directory> --verbose-linting

# Split schema by a single tag
pnpx @shopware/api-gen split <path-to-schema-file> --splitBy=tags --outputDir <output-directory> --filterBy "media"

# Split schema by a single path
pnpx @shopware/api-gen split <path-to-schema-file> --splitBy=paths --outputDir <output-directory> --filterBy "/api/_action/media/{mediaId}/upload"
```

### Programmatic usage

Each command can also be used programmatically within your own scripts:
Expand Down Expand Up @@ -299,6 +326,19 @@ await validateJson({
});
```

#### `split`

```ts
import { split } from "@shopware/api-gen";

await split({
schemaFile: "path/to/your/schema.json",
outputDir: "path/to/output/directory",
splitBy: "tags", // or "paths"
// filterBy: "TagName" // optional filter
});
```

> [!NOTE]
> Make sure that the required environment variables are set for the node process when executing commands programmatically.

Expand Down
42 changes: 42 additions & 0 deletions packages/api-gen/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import packageJson from "../package.json";
// import { version } from "../package.json";
import { generate } from "./commands/generate";
import { loadSchema } from "./commands/loadSchema";
import { split } from "./commands/split";
import type { SplitOptions } from "./commands/split";
import { validateJson } from "./commands/validateJson";

export interface CommonOptions {
Expand Down Expand Up @@ -103,6 +105,46 @@ yargs(hideBin(process.argv))
},
async (args) => validateJson(args),
)
.command(
"split <schemaFile>",
"Split OpenAPI schema into smaller files by tags or paths",
(args) => {
return commonOptions(args)
.option("outputDir", {
alias: "o",
type: "string",
default: "./output",
describe: "output directory for split files",
})
.positional("schemaFile", {
type: "string",
describe: "path to the schema file",
})
.option("splitBy", {
alias: "s",
type: "string",
default: "tags",
choices: ["tags", "paths"],
describe: "split by tags or paths",
})
.option("filterBy", {
alias: "f",
type: "string",
describe: "filter by a specific tag or path",
})
.option("verbose-linting", {
type: "boolean",
default: false,
describe: "show detailed linting errors",
})
.option("list", {
type: "string",
choices: ["tags", "paths"],
describe: "list all available tags or paths and exit",
});
},
async (args) => split(args as unknown as SplitOptions),
)
.showHelpOnFail(false)
.alias("h", "help")
.version("version", packageJson.version)
Expand Down
157 changes: 157 additions & 0 deletions packages/api-gen/src/commands/split.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { bundle, lint, loadConfig } from "@redocly/openapi-core";
import { format } from "prettier";
import {
createNewSchema,
filterPathsByTag,
getTags,
getTagsFromPath,
getUniquePaths,
getUsedComponents,
removeUnusedComponents,
} from "../utils/schemaSplitter";

export type SplitOptions = {
outputDir?: string;
schemaFile: string;
filterBy?: string;
splitBy?: "tags" | "paths";
verboseLinting?: boolean;
list?: "tags" | "paths";
};

export async function split(options: SplitOptions): Promise<void> {
const { outputDir, schemaFile, filterBy, splitBy, verboseLinting, list } =
options;

if (!schemaFile) {
throw new Error(`Schema file not found: ${schemaFile}`);
}

const finalSplitBy = splitBy || "tags";

const config = await loadConfig({
// TODO: add config if needed
});

const document = await bundle({
ref: schemaFile,
config,
dereference: false,
});

const problems = await lint({
ref: schemaFile,
config,
});

const errors = problems.filter((p) => p.severity === "error");
const warnings = problems.filter((p) => p.severity === "warn");

if (errors.length > 0 || warnings.length > 0) {
console.error(
`Schema has ${errors.length} errors and ${warnings.length} warnings.\n`,
);
if (verboseLinting) {
console.error("Details:", problems);
}
}

const paths = getUniquePaths(document.bundle.parsed);
const tags = getTags(document.bundle.parsed);

if (list) {
if (list === "tags") {
console.log(
tags
.map((t) => `"${t.name.toLowerCase()}"`)
.sort()
.join(", "),
);
} else if (list === "paths") {
console.log(
paths
.map((p) => `"${p}"`)
.sort()
.join(", "),
);
} else {
throw new Error(`Invalid list option: ${list}`);
}
return;
}

if (finalSplitBy !== "tags" && finalSplitBy !== "paths") {
throw new Error(`Invalid splitBy option: ${finalSplitBy}`);
}

console.log(`Splitting by ${finalSplitBy}...`);

if (finalSplitBy === "tags") {
for (const tag of tags) {
if (filterBy && tag.name.toLowerCase() !== filterBy.toLowerCase()) {
continue;
}

const newSchema = createNewSchema(document.bundle.parsed);
newSchema.paths = filterPathsByTag(document.bundle.parsed, tag.name);
if (newSchema.info) {
newSchema.info.title = `${newSchema.info.title} - ${tag.name}`;
}
newSchema.tags = [tag];

const usedComponents = getUsedComponents(newSchema);
const finalSchema = removeUnusedComponents(newSchema, usedComponents);

const fileName = `${tag.name.replace(/ /g, "-")}.json`.toLowerCase();
const outputPath = resolve(outputDir || "output", fileName);

mkdirSync(outputDir || "output", { recursive: true });
const formattedSchema = await format(
JSON.stringify(finalSchema, null, 2),
{
parser: "json",
},
);
writeFileSync(outputPath, formattedSchema);
console.log(`Generated ${outputPath}`);
}
} else if (finalSplitBy === "paths") {
for (const path of paths) {
if (filterBy && path !== filterBy) {
continue;
}

const newSchema = createNewSchema(document.bundle.parsed);
newSchema.paths = {
[path]: document.bundle.parsed.paths?.[path],
};
if (newSchema.info) {
newSchema.info.title = `${newSchema.info.title} - ${path.replace(
"/",
"_",
)}`;
}
newSchema.tags = getTagsFromPath(document.bundle.parsed, path);

const usedComponents = getUsedComponents(newSchema);
const finalSchema = removeUnusedComponents(newSchema, usedComponents);

const fileName = `${path
.replace(/[^a-zA-Z0-9]/g, "-")
.replace(/^-/, "")}.json`.toLowerCase();
const outputPath = resolve(outputDir || "output", fileName);

mkdirSync(outputDir || "output", { recursive: true });
const formattedSchema = await format(
JSON.stringify(finalSchema, null, 2),
{
parser: "json",
},
);
writeFileSync(outputPath, formattedSchema);
console.log(`Generated ${outputPath}`);
}
}
}
Loading
Loading