Repository: Redocly/redocly-cli
Pull request #736 (2022-06-22 14:14:45)

feat: add new decorators filter-in and filter-out
## What/Why/How?
 Add new decorator filter-in and filter-out
## Reference

Closes https://github.com/Redocly/redocly-cli/issues/599

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


IgorKarpiuk: @RomanHotsiy Is it the expected result?

## Filter-in

#### Input yaml

```yaml
     openapi: 3.0.0
     paths:
        /pet:
            x-audience: [Public, Global]
            post:
              summary: test
              requestBody:
                content:
                  application/x-www-form-urlencoded:
                    schema:
                      type: object
          /user:
            x-audience: [Public, Protected]
            post:
              summary: test
              requestBody:
                content:
                  application/json:
                    schema:
                      type: object  
```
#### Decorator config

```yaml
    lint:
      decorators:
        filter-in:
          property: x-audience
          value: [Public, Global]
          matchStrategy: all
```

#### Result

```yaml
     openapi: 3.0.0
     paths:
        /pet:
            x-audience: [Public, Global]
            post:
              summary: test
              requestBody:
                content:
                  application/x-www-form-urlencoded:
                    schema:
                      type: object
```

## Filter-out

#### Input yaml

```yaml
     openapi: 3.0.0
     paths:
        /pet:
            x-audience: [Private, Protected]
            post:
              summary: test
              requestBody:
                content:
                  application/x-www-form-urlencoded:
                    schema:
                      type: object
          /user:
            x-audience: [Public, Global]
            post:
              summary: test
              requestBody:
                content:
                  application/json:
                    schema:
                      type: object  
```
#### Decorator config

```yaml
    lint:
      decorators:
        filter-out:
          property: x-audience
          value: [Private, Protected]
          matchStrategy: all
```

#### Result

```yaml
     openapi: 3.0.0
     paths:
         /user:
           x-audience: [Public, Global]
           post:
             summary: test
             requestBody:
               content:
                 application/json:
                   schema:
                     type: object  
```
github-actions[bot]: <!-- jest coverage report action at  -->

## Coverage report 


### Total coverage

| Status | Category   | Percentage | Covered / Total |
| :----: | :--------- | :--------- | :-------------: |
|   🟡   | Statements | 67.46%     |    2807/4161    |
|   🟡   | Branches   | 60.45%     |    1539/2546    |
|   🔴   | Functions  | 58.97%     |     447/758     |
|   🟡   | Lines      | 67.69%     |    2602/3844    |

> Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold


<details><summary>Show new covered files 🌑</summary>


### Coverage of new files

| Status | Filename                                                                                              | Statements | Branches | Functions | Lines   |
| :----: | :---------------------------------------------------------------------------------------------------- | :--------- | :------- | :-------- | :------ |
|   🟢   | home/runner/work/redocly-cli/redocly-cli/packages/core/src/decorators/common/filters/filter-in.ts     | 100%       | 83.33%   | 100%      | 100%    |
|   🟢   | home/runner/work/redocly-cli/redocly-cli/packages/core/src/decorators/common/filters/filter-helper.ts | 83.33%     | 86.36%   | 100%      | 82.22%  |
|   🟢   | home/runner/work/redocly-cli/redocly-cli/packages/core/src/decorators/common/filters/filter-out.ts    | 100%       | 83.33%   | 100%      | 100%    |

> Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold


</details>



<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 2ac9ff8119779c1c35b220bbb1ceda04c602d218</p>


Patch:
diff --git a/packages/core/src/decorators/common/filters/filter-helper.ts b/packages/core/src/decorators/common/filters/filter-helper.ts
new file mode 100644
index 00000000..6a166391
--- /dev/null
+++ b/packages/core/src/decorators/common/filters/filter-helper.ts
@@ -0,0 +1,72 @@
+import { UserContext } from '../../../walk';
+import { isRef } from '../../../ref-utils';
+import { isEmptyArray, isEmptyObject, isPlainObject } from '../../../utils';
+
+export function filter(node: any, ctx: UserContext, criteria: (item: any) => boolean) {
+  const { parent, key } = ctx;
+  let didDelete = false;
+  if (Array.isArray(node)) {
+    for (let i = 0; i < node.length; i++) {
+      if (isRef(node[i])) {
+        const resolved = ctx.resolve(node[i]);
+        if (criteria(resolved.node)) {
+          node.splice(i, 1);
+          didDelete = true;
+          i--;
+        }
+      }
+      if (criteria(node[i])) {
+        node.splice(i, 1);
+        didDelete = true;
+        i--;
+      }
+    }
+  } else if (isPlainObject(node)) {
+    for (const key of Object.keys(node)) {
+      node = node as any;
+      if (isRef(node[key])) {
+        const resolved = ctx.resolve(node[key]);
+        if (criteria(resolved.node)) {
+          delete node[key];
+          didDelete = true;
+        }
+      }
+      if (criteria(node[key])) {
+        delete node[key];
+        didDelete = true;
+      }
+    }
+  }
+  if (didDelete && (isEmptyObject(node) || isEmptyArray(node))) {
+    delete parent[key];
+  }
+}
+
+export function checkIfMatchByStrategy(
+  nodeValue: any,
+  decoratorValue: any,
+  strategy: 'all' | 'any'
+): boolean {
+  if (!nodeValue || !decoratorValue) {
+    return false;
+  }
+
+  if (!Array.isArray(decoratorValue) && !Array.isArray(nodeValue)) {
+    return nodeValue === decoratorValue;
+  }
+
+  decoratorValue = toArrayIfNeeded<string>(decoratorValue);
+  nodeValue = toArrayIfNeeded<string>(nodeValue);
+
+  if (strategy === 'any') {
+    return decoratorValue.some((item: string) => nodeValue.includes(item));
+  }
+  if (strategy === 'all') {
+    return decoratorValue.every((item: string) => nodeValue.includes(item));
+  }
+  return false;
+}
+
+function toArrayIfNeeded<T>(value: T | T[]): T[] {
+  return Array.isArray(value) ? value : [value];
+}
diff --git a/packages/core/src/decorators/common/filters/filter-in.ts b/packages/core/src/decorators/common/filters/filter-in.ts
new file mode 100644
index 00000000..88c602a9
--- /dev/null
+++ b/packages/core/src/decorators/common/filters/filter-in.ts
@@ -0,0 +1,18 @@
+import { Oas2Decorator, Oas3Decorator } from '../../../visitors';
+import { checkIfMatchByStrategy, filter } from './filter-helper';
+
+const DEFAULT_STRATEGY = 'any';
+
+export const FilterIn: Oas3Decorator | Oas2Decorator = ({ property, value, matchStrategy }) => {
+  const strategy = matchStrategy || DEFAULT_STRATEGY;
+  const filterInCriteria = (item: any) =>
+    item?.[property] && !checkIfMatchByStrategy(item?.[property], value, strategy);
+
+  return {
+    any: {
+      enter: (node, ctx) => {
+        filter(node, ctx, filterInCriteria);
+      },
+    },
+  };
+};
diff --git a/packages/core/src/decorators/common/filters/filter-out.ts b/packages/core/src/decorators/common/filters/filter-out.ts
new file mode 100644
index 00000000..c7e026c9
--- /dev/null
+++ b/packages/core/src/decorators/common/filters/filter-out.ts
@@ -0,0 +1,18 @@
+import { Oas2Decorator, Oas3Decorator } from '../../../visitors';
+import { checkIfMatchByStrategy, filter } from './filter-helper';
+
+const DEFAULT_STRATEGY = 'any';
+
+export const FilterOut: Oas3Decorator | Oas2Decorator = ({ property, value, matchStrategy }) => {
+  const strategy = matchStrategy || DEFAULT_STRATEGY;
+  const filterOutCriteria = (item: any) =>
+    checkIfMatchByStrategy(item?.[property], value, strategy);
+
+  return {
+    any: {
+      enter: (node, ctx) => {
+        filter(node, ctx, filterOutCriteria);
+      },
+    },
+  };
+};
diff --git a/packages/core/src/decorators/oas2/index.ts b/packages/core/src/decorators/oas2/index.ts
index f68ea672..7939ecd1 100644
--- a/packages/core/src/decorators/oas2/index.ts
+++ b/packages/core/src/decorators/oas2/index.ts
@@ -4,11 +4,15 @@ import { OperationDescriptionOverride } from '../common/operation-description-ov
 import { TagDescriptionOverride } from '../common/tag-description-override';
 import { InfoDescriptionOverride } from '../common/info-description-override';
 import { RemoveXInternal } from '../common/remove-x-internal';
+import { FilterIn } from '../common/filters/filter-in';
+import { FilterOut } from '../common/filters/filter-out';
 
 export const decorators = {
   'registry-dependencies': RegistryDependencies as Oas2Decorator,
   'operation-description-override': OperationDescriptionOverride as Oas2Decorator,
   'tag-description-override': TagDescriptionOverride as Oas2Decorator,
   'info-description-override': InfoDescriptionOverride as Oas2Decorator,
-  'remove-x-internal': RemoveXInternal as Oas2Decorator
+  'remove-x-internal': RemoveXInternal as Oas2Decorator,
+  'filter-in': FilterIn as Oas2Decorator,
+  'filter-out': FilterOut as Oas2Decorator,
 };
diff --git a/packages/core/src/decorators/oas3/index.ts b/packages/core/src/decorators/oas3/index.ts
index fa39e676..2289e522 100644
--- a/packages/core/src/decorators/oas3/index.ts
+++ b/packages/core/src/decorators/oas3/index.ts
@@ -4,11 +4,15 @@ import { OperationDescriptionOverride } from '../common/operation-description-ov
 import { TagDescriptionOverride } from '../common/tag-description-override';
 import { InfoDescriptionOverride } from '../common/info-description-override';
 import { RemoveXInternal } from '../common/remove-x-internal';
+import { FilterIn } from '../common/filters/filter-in';
+import { FilterOut } from '../common/filters/filter-out';
 
 export const decorators = {
   'registry-dependencies': RegistryDependencies as Oas3Decorator,
   'operation-description-override': OperationDescriptionOverride as Oas3Decorator,
   'tag-description-override': TagDescriptionOverride as Oas3Decorator,
   'info-description-override': InfoDescriptionOverride as Oas3Decorator,
-  'remove-x-internal': RemoveXInternal as Oas3Decorator
+  'remove-x-internal': RemoveXInternal as Oas3Decorator,
+  'filter-in': FilterIn as Oas3Decorator,
+  'filter-out': FilterOut as Oas3Decorator,
 };


Test patch:
diff --git a/packages/core/src/decorators/__tests__/filter-in.test.ts b/packages/core/src/decorators/__tests__/filter-in.test.ts
new file mode 100644
index 00000000..c1191664
--- /dev/null
+++ b/packages/core/src/decorators/__tests__/filter-in.test.ts
@@ -0,0 +1,310 @@
+import { outdent } from 'outdent';
+import { bundleDocument } from '../../bundle';
+import { BaseResolver } from '../../resolve';
+import { makeConfig, parseYamlToDocument, yamlSerializer } from '../../../__tests__/utils';
+
+describe('oas3 filter-in', () => {
+  expect.addSnapshotSerializer(yamlSerializer);
+
+  const inputDoc = parseYamlToDocument(
+    outdent`
+        openapi: 3.0.0
+        paths:
+          /pet:
+            x-audience: Global
+            post:
+              summary: test
+          /user:
+            x-audience: [Public, Global]
+            post:
+              summary: test
+          /post:
+            get:
+              summary: test                        
+          /order:
+            x-audience: [Public, Protected]
+            post:
+              operationId: storeOrder
+              callbacks:
+                x-access: protected`
+  );
+
+  it('should include /user path and remove y parameter', async () => {
+    const testDocument = parseYamlToDocument(
+      outdent`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          x-access: private
+          get:
+            parameters:
+              - $ref: '#/components/parameters/x'
+        /user:
+          x-access: public
+          get:
+            parameters:
+              - $ref: '#/components/parameters/y'      
+      components:
+        parameters:
+          x:
+            name: x
+          y:
+            x-access: private
+            name: y            
+    `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDocument,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig({}, { 'filter-in': { value: 'public', property: 'x-access' } }),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /user:
+          x-access: public
+          get: {}
+      components:
+        parameters:
+          x:
+            name: x
+
+    `);
+  });
+
+  it('should include /order and /post paths', async () => {
+    const { bundle: res } = await bundleDocument({
+      document: inputDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-in': {
+            property: 'x-audience',
+            value: ['Public', 'Protected'],
+            matchStrategy: 'all',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /post:
+          get:
+            summary: test
+        /order:
+          x-audience:
+            - Public
+            - Protected
+          post:
+            operationId: storeOrder
+            callbacks:
+              x-access: protected
+      components: {}
+
+    `);
+  });
+
+  it('should include all paths', async () => {
+    const testDoc = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          /pet:
+            x-audience: Global
+            post:
+              summary: test
+          /user:
+            x-audience: [Public, Global]
+            post:
+              summary: test            
+          /order:
+            x-audience: [Public, Protected]
+            post:
+              operationId: storeOrder
+              parameters:
+                - name: api_key
+                 `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-in': {
+            property: 'x-audience',
+            value: ['Public', 'Global'],
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          x-audience: Global
+          post:
+            summary: test
+        /user:
+          x-audience:
+            - Public
+            - Global
+          post:
+            summary: test
+        /order:
+          x-audience:
+            - Public
+            - Protected
+          post:
+            operationId: storeOrder
+            parameters:
+              - name: api_key
+      components: {}
+
+    `);
+  });
+
+  it('should include path without x-audience property ', async () => {
+    const { bundle: res } = await bundleDocument({
+      document: inputDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-in': {
+            property: 'x-audience',
+            value: 'non-existing-audience',
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /post:
+          get:
+            summary: test
+      components: {}
+
+    `);
+  });
+
+  it('should include /pet and /account without post method', async () => {
+    const testDoc = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          /pet:
+            x-audience: Global
+            post:
+              summary: test
+          /user:
+            x-audience: Private
+            post:
+              summary: test
+            get:
+               summary: get
+               x-audience: [Public, Global]
+          /account:
+            get:
+               summary: get
+            post: 
+               summary: test
+               x-audience: Private     
+                 `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-in': {
+            property: 'x-audience',
+            value: ['Public', 'Global'],
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          x-audience: Global
+          post:
+            summary: test
+        /account:
+          get:
+            summary: get
+      components: {}
+
+    `);
+  });
+});
+
+describe('oas2 filter-in', () => {
+  it('should include only one parameter and not include response', async () => {
+    const testDoc = parseYamlToDocument(
+      outdent`
+        swagger: '2.0'
+        host: api.instagram.com
+        paths:
+          '/geographies/{geo-id}/media/recent':
+            get:
+              parameters:
+                - description: The geography ID.
+                  x-access: private
+                  in: path
+                  name: geo-id
+                  required: true
+                  type: string
+                - description: Max number of media to return.
+                  x-access: public
+                  format: int32
+                  in: query
+                  name: count
+                  required: false
+                  type: integer
+              responses:
+                '200':
+                  description: List of recent media entries.
+                  x-access: [private, protected]
+      `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-in': {
+            property: 'x-access',
+            value: ['public', 'global'],
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      swagger: '2.0'
+      host: api.instagram.com
+      paths:
+        /geographies/{geo-id}/media/recent:
+          get:
+            parameters:
+              - description: Max number of media to return.
+                x-access: public
+                format: int32
+                in: query
+                name: count
+                required: false
+                type: integer
+
+    `);
+  });
+});
diff --git a/packages/core/src/decorators/__tests__/filter-out.test.ts b/packages/core/src/decorators/__tests__/filter-out.test.ts
new file mode 100644
index 00000000..04ebfa43
--- /dev/null
+++ b/packages/core/src/decorators/__tests__/filter-out.test.ts
@@ -0,0 +1,227 @@
+import { outdent } from 'outdent';
+import { bundleDocument } from '../../bundle';
+import { BaseResolver } from '../../resolve';
+import { makeConfig, parseYamlToDocument, yamlSerializer } from '../../../__tests__/utils';
+
+describe('oas3 filter-out', () => {
+  expect.addSnapshotSerializer(yamlSerializer);
+
+  const inputDoc = parseYamlToDocument(
+    outdent`
+        openapi: 3.0.0
+        paths:
+          /pet:
+            x-audience: Private
+            post:
+              summary: test
+          /user:
+            x-audience: Protected
+            post:
+              summary: test          
+          /order:
+            x-audience: [Private, Protected]
+            post:
+              operationId: storeOrder
+              parameters:
+                - name: api_key
+              callbacks:
+                x-access: protected
+                orderInProgress:
+                  x-internal: true
+             `
+  );
+
+  it('should remove /pet path and y parameter', async () => {
+    const testDocument = parseYamlToDocument(
+      outdent`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          x-access: private
+          get:
+            parameters:
+              - $ref: '#/components/parameters/y'
+      components:
+        parameters:
+          x:
+            name: x
+          y:
+            x-access: private
+            name: y  
+    `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDocument,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig({}, { 'filter-out': { property: 'x-access', value: 'private' } }),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+          openapi: 3.0.0
+          components:
+            parameters:
+              x:
+                name: x
+
+        `);
+  });
+
+  it('should remove only /order path', async () => {
+    const { bundle: res } = await bundleDocument({
+      document: inputDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-out': {
+            property: 'x-audience',
+            value: ['Private', 'Protected'],
+            matchStrategy: 'all',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          x-audience: Private
+          post:
+            summary: test
+        /user:
+          x-audience: Protected
+          post:
+            summary: test
+      components: {}
+
+    `);
+  });
+
+  it('should remove all paths', async () => {
+    const { bundle: res } = await bundleDocument({
+      document: inputDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-out': {
+            property: 'x-audience',
+            value: ['Private', 'Protected'],
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+        openapi: 3.0.0
+        components: {}
+        
+        `);
+  });
+
+  it('should remove requestBody', async () => {
+    const testDoc = parseYamlToDocument(
+      outdent`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          post:
+            summary: test
+            requestBody:
+              content:
+                x-access: private
+                application/x-www-form-urlencoded:
+                  schema:
+                    type: object
+      components: {}
+            
+        `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-out': {
+            property: 'x-access',
+            value: 'private',
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      openapi: 3.0.0
+      paths:
+        /pet:
+          post:
+            summary: test
+      components: {}
+
+    `);
+  });
+});
+
+describe('oas2 filter-out', () => {
+  it('should clean all parameters and responses ', async () => {
+    const testDoc = parseYamlToDocument(
+      outdent`
+        swagger: '2.0'
+        host: api.instagram.com
+        paths:
+          '/geographies/{geo-id}/media/recent':
+            get:
+              parameters:
+                - description: The geography ID.
+                  x-access: private
+                  in: path
+                  name: geo-id
+                  required: true
+                  type: string
+                - description: Max number of media to return.
+                  x-access: protected
+                  format: int32
+                  in: query
+                  name: count
+                  required: false
+                  type: integer
+              responses:
+                $ref: '#/components/response/200'
+        components:
+          response:
+            '200':
+                  description: List of recent media entries.
+                  x-access: [protected, public]            
+      `
+    );
+    const { bundle: res } = await bundleDocument({
+      document: testDoc,
+      externalRefResolver: new BaseResolver(),
+      config: await makeConfig(
+        {},
+        {
+          'filter-out': {
+            property: 'x-access',
+            value: ['private', 'protected'],
+            matchStrategy: 'any',
+          },
+        }
+      ),
+    });
+    expect(res.parsed).toMatchInlineSnapshot(`
+      swagger: '2.0'
+      host: api.instagram.com
+      paths:
+        /geographies/{geo-id}/media/recent:
+          get: {}
+      components:
+        response:
+          '200':
+            description: List of recent media entries.
+            x-access:
+              - protected
+              - public
+
+    `);
+  });
+});