Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(converter): add complete code infrastructure #3755

Merged
merged 4 commits into from
Jan 30, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions packages/apidom-converter/config/webpack/browser.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ const browser = {
},
resolve: {
extensions: ['.ts', '.mjs', '.js', '.json'],
fallback: {
fs: false,
path: false,
},
},
module: {
rules: [
{
test: /\.wasm$/,
loader: 'file-loader',
type: 'javascript/auto',
},
{
test: /\.(ts|js)?$/,
exclude: /node_modules/,
Expand Down Expand Up @@ -48,9 +57,18 @@ const browserMin = {
},
resolve: {
extensions: ['.ts', '.mjs', '.js', '.json'],
fallback: {
fs: false,
path: false,
},
},
module: {
rules: [
{
test: /\.wasm$/,
loader: 'file-loader',
type: 'javascript/auto',
},
{
test: /\.(ts|js)?$/,
exclude: /node_modules/,
Expand Down
7 changes: 6 additions & 1 deletion packages/apidom-converter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
"author": "Vladimír Gorej",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime-corejs3": "^7.20.7"
"@babel/runtime-corejs3": "^7.20.7",
"@swagger-api/apidom-core": "^0.93.0",
"@swagger-api/apidom-ns-openapi-3-0": "^0.93.0",
"@swagger-api/apidom-ns-openapi-3-1": "^0.93.0",
"@swagger-api/apidom-reference": "^0.93.0",
"stampit": "^4.3.2"
},
"files": [
"cjs/",
Expand Down
5 changes: 5 additions & 0 deletions packages/apidom-converter/src/errors/ConvertError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ApiDOMError } from '@swagger-api/apidom-error';

class ConvertError extends ApiDOMError {}

export default ConvertError;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ConvertError from './ConvertError';

class UnmatchedConvertStrategyError extends ConvertError {}

export default UnmatchedConvertStrategyError;
37 changes: 35 additions & 2 deletions packages/apidom-converter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
const foo = Symbol('foo');
import { ParseResultElement } from '@swagger-api/apidom-core';
import { mergeOptions, bundle, File } from '@swagger-api/apidom-reference';

export default foo;
import defaultOptions, { ConverterOptions } from './options';
import ConvertError from './errors/ConvertError';
import UnmatchedConvertStrategyError from './errors/UnmatchedConvertStrategyError';

export { ConvertError, UnmatchedConvertStrategyError };

/**
* `convertApiDOM` already assumes that the ApiDOM is bundled.
*/
export const convertApiDOM = async (element: ParseResultElement, options = {}) => {
const mergedOptions = mergeOptions(defaultOptions, options || {}) as ConverterOptions;
const file = File({
uri: mergedOptions.resolve.baseURI,
parseResult: element,
mediaType: mergedOptions.convert.sourceMediaType || mergedOptions.parse.mediaType,
});
const strategy = mergedOptions.convert.strategies.find((s) => s.canConvert(file, mergedOptions));

if (typeof strategy === 'undefined') {
throw new UnmatchedConvertStrategyError(file.uri);
}

return strategy.convert(file, mergedOptions);
};

const convert = async (uri: string, options = {}) => {
const mergedOptions = mergeOptions(defaultOptions, options || {}) as ConverterOptions;
const parseResult = await bundle(uri, mergedOptions);

return convertApiDOM(parseResult, mergedOptions);
};

export default convert;
40 changes: 40 additions & 0 deletions packages/apidom-converter/src/options/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { options as referenceOptions } from '@swagger-api/apidom-reference';

import ConvertStrategy from '../strategies/ConvertStrategy';
import OpenAPI31ToOpenAPI30ConvertStrategy from '../strategies/openapi-3-1-to-openapi-3-0-3';

type ReferenceOptions = typeof referenceOptions;

interface ConvertOptions {
strategies: Array<ConvertStrategy>;
sourceMediaType: string;
targetMediaType: string;
}

export interface ConverterOptions extends ReferenceOptions {
readonly convert: ConvertOptions;
}

const defaultOptions: ConverterOptions = {
...referenceOptions,
convert: {
/**
* Determines strategies how ApiDOM is bundled.
* Strategy is determined by media type or by inspecting ApiDOM to be bundled.
*
* You can add additional bundle strategies of your own, replace an existing one with
* your own implementation, or remove any bundle strategy by removing it from the list.
*/
strategies: [new OpenAPI31ToOpenAPI30ConvertStrategy()],
/**
* Media type of source API definition.
*/
sourceMediaType: 'text/plain',
/**
* Media type of target API definition.
*/
targetMediaType: 'text/plain',
},
};

export default defaultOptions;
26 changes: 26 additions & 0 deletions packages/apidom-converter/src/strategies/ConvertStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import stampit from 'stampit';
import { ParseResultElement } from '@swagger-api/apidom-core';
import { File } from '@swagger-api/apidom-reference';

import type { ConverterOptions } from '../options';

type ExtractGenericType<T> = T extends stampit.Stamp<infer U> ? U : never;
export type IFile = ExtractGenericType<typeof File>;

export interface ConvertStrategyOptions {
readonly name: string;
}

abstract class ConvertStrategy {
public readonly name: string;

protected constructor({ name }: ConvertStrategyOptions) {
this.name = name;
}

abstract canConvert(file: IFile, options: ConverterOptions): boolean;

abstract convert(file: IFile, options: ConverterOptions): Promise<ParseResultElement>;
}

export default ConvertStrategy;
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
OpenApi3_0Element,
mediaTypes as openAPI3_0MediaTypes,
} from '@swagger-api/apidom-ns-openapi-3-0';
import {
isOpenApi3_1Element,
mediaTypes as openAPI3_1MediaTypes,
createToolbox,
keyMap,
getNodeType,
} from '@swagger-api/apidom-ns-openapi-3-1';
import {
ParseResultElement,
dispatchRefractorPlugins,
AnnotationElement,
cloneShallow,
} from '@swagger-api/apidom-core';

import ConvertStrategy, { IFile } from '../ConvertStrategy';
import openAPIVersionRefractorPlugin from './refractor-plugins/openapi-version';
import webhooksRefractorPlugin from './refractor-plugins/webhooks';
import type { ConverterOptions } from '../../options';

// eslint-disable-next-line @typescript-eslint/naming-convention
const openAPI3_0_3MediaTypes = [
openAPI3_0MediaTypes.findBy('3.0.3', 'generic'),
openAPI3_0MediaTypes.findBy('3.0.3', 'json'),
openAPI3_0MediaTypes.findBy('3.0.3', 'yaml'),
];

/* eslint-disable class-methods-use-this */
class OpenAPI31ToOpenAPI30ConvertStrategy extends ConvertStrategy {
constructor() {
super({ name: 'openapi-3-1-to-openapi-3-0-3' });
}

canConvert(file: IFile, options: ConverterOptions): boolean {
let hasRecognizedSourceMediaType = false;
const hasRecognizedTargetMediaType = openAPI3_0_3MediaTypes.includes(
options.convert.targetMediaType,
);

// source detection
if (openAPI3_1MediaTypes.includes(options.convert.sourceMediaType)) {
hasRecognizedSourceMediaType = true;
} else if (file.mediaType !== 'text/plain') {
hasRecognizedSourceMediaType = openAPI3_1MediaTypes.includes(file.mediaType);
} else if (isOpenApi3_1Element(file.parseResult?.result)) {
hasRecognizedSourceMediaType = true;
}

return hasRecognizedSourceMediaType && hasRecognizedTargetMediaType;
}

async convert(file: IFile): Promise<ParseResultElement> {
const parseResultElement = file.parseResult;
const annotations: AnnotationElement[] = [];
const converted = dispatchRefractorPlugins(
parseResultElement,
[openAPIVersionRefractorPlugin(), webhooksRefractorPlugin({ annotations })],
{
toolboxCreator: createToolbox,
visitorOptions: { keyMap, nodeTypeGetter: getNodeType },
},
);

const annotated = cloneShallow(converted);
annotations.forEach((a) => annotated.push(a));
annotated.replaceResult(OpenApi3_0Element.refract(converted.api));

return annotated;
}
}
/* eslint-enable class-methods-use-this */

export default OpenAPI31ToOpenAPI30ConvertStrategy;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { OpenapiElement as Openapi30Element } from '@swagger-api/apidom-ns-openapi-3-0';

const openAPIVersionRefractorPlugin = () => () => ({
visitor: {
OpenapiElement() {
return new Openapi30Element('3.0.3');
},
},
});

export default openAPIVersionRefractorPlugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { OpenApi3_1Element } from '@swagger-api/apidom-ns-openapi-3-1';
import { AnnotationElement, cloneShallow } from '@swagger-api/apidom-core';

type WebhooksRefractorPluginOptions = {
annotations: AnnotationElement[];
};

const webhooksRefractorPlugin =
({ annotations }: WebhooksRefractorPluginOptions) =>
() => ({
visitor: {
OpenApi3_1Element(element: OpenApi3_1Element) {
if (!element.hasKey('webhooks')) return undefined;

const copy = cloneShallow(element);
const annotation = new AnnotationElement(
'Webhooks are not supported in OpenAPI 3.0.3. They will be removed from the converted document.',
{ classes: ['warning'] },
{ code: 'webhooks' },
);

annotations.push(annotation);
copy.remove('webhooks');

return copy;
},
},
});

export default webhooksRefractorPlugin;
13 changes: 13 additions & 0 deletions packages/apidom-converter/test/__snapshots__/index.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`apidom-converter convert given URI should convert 1`] = `
{
"openapi": "3.0.3"
}
`;

exports[`apidom-converter convertApiDOM given ApiDOM data should convert 1`] = `
{
"openapi": "3.0.3"
}
`;
61 changes: 58 additions & 3 deletions packages/apidom-converter/test/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,62 @@
import { assert } from 'chai';
import path from 'node:path';
import { expect } from 'chai';
import { toJSON } from '@swagger-api/apidom-core';
import { mediaTypes as openAPI30MediaTypes } from '@swagger-api/apidom-parser-adapter-openapi-json-3-0';
import { mediaTypes as openAPI31MediaTypes } from '@swagger-api/apidom-parser-adapter-openapi-json-3-1';
import { parse } from '@swagger-api/apidom-reference';

import convert, { convertApiDOM } from '../src';

describe('apidom-converter', function () {
it('initial test', async function () {
assert.strictEqual(true, true);
context('convert', function () {
context('given URI', function () {
specify('should convert', async function () {
const fixturePath = path.join(
__dirname,
'strategies',
'openapi-3-1-to-openapi-3-0-3',
'refractor-plugins',
'openapi-version',
'fixtures',
'openapi-version.json',
);
const convertedParseResult = await convert(fixturePath, {
convert: {
sourceMediaType: openAPI31MediaTypes.findBy('3.1.0', 'json'),
targetMediaType: openAPI30MediaTypes.findBy('3.0.3', 'json'),
},
});

expect(toJSON(convertedParseResult.api!, undefined, 2)).toMatchSnapshot();
});
});
});

context('convertApiDOM', function () {
context('given ApiDOM data', function () {
specify('should convert', async function () {
const fixturePath = path.join(
__dirname,
'strategies',
'openapi-3-1-to-openapi-3-0-3',
'refractor-plugins',
'openapi-version',
'fixtures',
'openapi-version.json',
);
const parseResult = await parse(fixturePath);
const convertedParseResult = await convertApiDOM(parseResult, {
convert: {
sourceMediaType: openAPI31MediaTypes.findBy('3.1.0', 'json'),
targetMediaType: openAPI30MediaTypes.findBy('3.0.3', 'json'),
},
resolve: {
baseURI: fixturePath,
},
});

expect(toJSON(convertedParseResult.api!, undefined, 2)).toMatchSnapshot();
});
});
});
});
5 changes: 5 additions & 0 deletions packages/apidom-converter/test/mocha-bootstrap.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require('@babel/register')({ extensions: ['.js', '.ts'], rootMode: 'upward' });

const { options } = require('@swagger-api/apidom-reference');
const chai = require('chai');
const { jestSnapshotPlugin, addSerializer } = require('mocha-chai-jest-snapshot');

Expand All @@ -9,3 +10,7 @@ const jestStringSerializer = require('../../../scripts/jest-serializer-string.cj
chai.use(jestSnapshotPlugin());
addSerializer(jestApiDOMSerializer);
addSerializer(jestStringSerializer);

// setup allow list for file resolution
const [fileResolver] = options.resolve.resolvers;
fileResolver.fileAllowList = ['*'];