diff --git a/.github/workflows/unit-tests-mesh.yml b/.github/workflows/unit-tests-mesh.yml new file mode 100644 index 0000000..495f855 --- /dev/null +++ b/.github/workflows/unit-tests-mesh.yml @@ -0,0 +1,28 @@ +name: Run Unit Tests for the graphql-mesh library + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test -w graphql-mesh diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests-spl.yml similarity index 90% rename from .github/workflows/unit-tests.yml rename to .github/workflows/unit-tests-spl.yml index 49ac0f0..14f56eb 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests-spl.yml @@ -1,4 +1,4 @@ -name: Run Unit Tests for directive-spl library +name: Run Unit Tests for the directive-spl library on: push: diff --git a/packages/graphql-mesh/.meshrc.ts b/packages/graphql-mesh/.meshrc.ts index 59125c3..02af6cc 100644 --- a/packages/graphql-mesh/.meshrc.ts +++ b/packages/graphql-mesh/.meshrc.ts @@ -1,5 +1,5 @@ import { YamlConfig } from '@graphql-mesh/types' -import ConfigFromSwaggers from './utils/ConfigFromSwaggers' +import ConfigFromSwaggers from './utils/configFromSwaggers' import { splDirectiveTypeDef } from 'directive-spl' import { catchHTTPErrorDirectiveTypeDef } from './directives/catchHTTPError' import { headersDirectiveTypeDef } from './directives/headers' diff --git a/packages/graphql-mesh/package.json b/packages/graphql-mesh/package.json index 56a6125..04a6a48 100644 --- a/packages/graphql-mesh/package.json +++ b/packages/graphql-mesh/package.json @@ -5,7 +5,8 @@ "downloadswaggers": "NODE_TLS_REJECT_UNAUTHORIZED='0' sucrase-node ./scripts/download-sources.ts", "postinstall": "patch-package", "serve": "npm run build && sucrase-node serve.ts", - "start": "npm run downloadswaggers && mesh dev" + "start": "npm run downloadswaggers && mesh dev", + "test": "vitest" }, "dependencies": { "@graphql-mesh/cache-localforage": "^0.96.2", diff --git a/packages/graphql-mesh/tests/cases/configFromSwaggers.test.ts b/packages/graphql-mesh/tests/cases/configFromSwaggers.test.ts new file mode 100644 index 0000000..510a713 --- /dev/null +++ b/packages/graphql-mesh/tests/cases/configFromSwaggers.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest' +import ConfigFromSwaggers from '../../utils/configFromSwaggers' +import { globSync } from 'glob' +import { readFileSync } from 'node:fs' +import { Spec, Catalog } from '../../types' + +// Mock dependencies +vi.mock('glob', () => ({ globSync: vi.fn() })) +vi.mock('node:fs', () => ({ readFileSync: vi.fn() })) + +describe('ConfigFromSwaggers tests', () => { + let instance: ConfigFromSwaggers + let mockSpecs: Spec[] + let mockConfig: any + + beforeAll(() => { + // Mock Swaggers + mockSpecs = [ + { + openapi: '3.0.0', + info: { + title: 'getOwnerOfVehicleById', + version: '0.0.1' + }, + paths: { + '/vehicle/{id}/owner': { + get: { + operationId: 'getOwnerOfVehicleById', + summary: 'Get the owner of a specific vehicle.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Person' + } + } + } + } + } + } + } + }, + components: { + schemas: { + Person: { + type: 'object', + properties: { + id: { + type: 'integer' + } + } + } + } + } + }, + { + openapi: '3.0.0', + info: { + title: 'getVehicles', + version: '0.0.1' + }, + paths: { + '/vehicles': { + get: { + operationId: 'getVehicles', + summary: 'Get all vehicles.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Vehicles' + } + } + } + } + } + } + } + }, + components: { + schemas: { + Vehicles: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Vehicle' + } + } + } + }, + Vehicle: { + type: 'object', + discriminator: { + propertyName: 'type', + mapping: { + Vehicle: '#/components/schemas/Vehicle', + Car: '#/components/schemas/Car', + Bike: '#/components/schemas/Bike' + } + }, + properties: { + id: { + type: 'integer' + }, + type: { + type: 'string' + } + } + }, + Car: { + allOf: [ + { + $ref: '#/components/schemas/Vehicle' + }, + { + type: 'object', + properties: { + fuelType: { + type: 'string' + } + } + } + ] + }, + Bike: { + allOf: [ + { + $ref: '#/components/schemas/Vehicle' + }, + { + type: 'object', + properties: { + bikeType: { + type: 'string' + } + } + } + ] + } + } + } + } + ] + // Mock config + mockConfig = { + sources: [ + { + name: 'getVehicles', + handler: { + openapi: { + operationHeaders: {} + } + } + }, + { + name: 'getOwnerOfVehicleById', + handler: { + openapi: { + operationHeaders: {} + } + } + } + ] + } + // Mock globSync + vi.mocked(globSync).mockReturnValue([ + 'mocks/getOwnerOfVehicleById.json', + 'mocks/getVehicles.json' + ]) + // Mock readFileSync to return values of mockSpecs alternatively + const readFileSyncMock = (path: string, options?: { encoding?: string | null; flag?: string } | BufferEncoding) => { + let data; + if (path === 'mocks/getOwnerOfVehicleById.json') { + data = JSON.stringify(mockSpecs[0]) + } else if (path === 'mocks/getVehicles.json') { + data = JSON.stringify(mockSpecs[1]) + } + + const encoding = typeof options === 'string' ? options : options?.encoding; + if (encoding === 'buffer' || !encoding) { + return Buffer.from(data ?? ''); + } + return data; + }; + vi.mocked(readFileSync).mockImplementation(readFileSyncMock); + + vi.resetModules() + + instance = new ConfigFromSwaggers() + + instance.config = mockConfig + }) + + // Specs tests + it('should initialize swaggers and specs correctly', () => { + expect(instance.swaggers).toEqual([ + 'mocks/getOwnerOfVehicleById.json', + 'mocks/getVehicles.json' + ]) + expect(instance.specs).toEqual(mockSpecs) + }) + + // Catalog test + it('should build the catalog correctly', () => { + const expectedCatalog: Catalog = { + '/vehicle/{id}/owner': { + operationIds: ['getOwnerOfVehicleById'], + type: 'Person', + swaggers: ['mocks/getOwnerOfVehicleById.json'] + }, + '/vehicles': { + operationIds: ['getVehicles'], + type: 'Vehicles', + swaggers: ['mocks/getVehicles.json'] + } + } + expect(instance.catalog).toEqual(expectedCatalog) + }) + + // Test function to get available types + it('should return all the available types', () => { + const expectedTypes = ['Person', 'Vehicles', 'Vehicle', 'Car', 'Bike'] + expect(instance.getAvailableTypes()).toEqual(expectedTypes) + }) + + // Test function to get interfaces with their children + it('should return interfaces with children correctly', () => { + const expectedInterfacesWithChildren: Record = { + Vehicle: ['Car', 'Bike'] + } + expect(instance.getInterfacesWithChildren()).toEqual(expectedInterfacesWithChildren) + }) + + // Test function to get OpenAPI sources + it('should get OpenAPI sources correctly', () => { + const expectedOpenApiSources = [ + { + name: 'getOwnerOfVehicleById', + handler: { + openapi: { + source: 'mocks/getOwnerOfVehicleById.json', + endpoint: '{env.ENDPOINT}', + ignoreErrorResponses: true, + operationHeaders: { + Authorization: `{context.headers["authorization"]}` + } + } + }, + transforms: undefined + }, + { + name: 'getVehicles', + handler: { + openapi: { + source: 'mocks/getVehicles.json', + endpoint: '{env.ENDPOINT}', + ignoreErrorResponses: true, + operationHeaders: { + Authorization: `{context.headers["authorization"]}` + } + } + }, + transforms: undefined + } + ] + expect(instance.getOpenApiSources()).toEqual(expectedOpenApiSources) + }) + + // Test function to get non-OpenAPI sources + it('should get other sources correctly', () => { + const expectedOtherSources = [] + expect(instance.getOtherSources()).toEqual(expectedOtherSources) + }) + + // Test function to get config from Swaggers + it('should return the complete mesh config from swaggers', () => { + const meshConfig = instance.getMeshConfigFromSwaggers() + expect(meshConfig.defaultConfig).toEqual(mockConfig) + expect(meshConfig.additionalTypeDefs).toContain('type LinkItem') + expect(meshConfig.additionalResolvers).toBeDefined() + expect(meshConfig.sources.length).toEqual(2) + }) +}) diff --git a/packages/graphql-mesh/tests/cases/generateTypeDefsAndResolvers.test.ts b/packages/graphql-mesh/tests/cases/generateTypeDefsAndResolvers.test.ts new file mode 100644 index 0000000..e5959f0 --- /dev/null +++ b/packages/graphql-mesh/tests/cases/generateTypeDefsAndResolvers.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect } from 'vitest' +import { generateTypeDefsAndResolversFromSwagger } from '../../utils/generateTypeDefsAndResolvers' +import { Spec, ConfigExtension, Catalog } from '../../types' + +/** + * ---------- MOCKS ---------- + */ + +const mockSwaggerWithPrefix: Spec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '0.0.1' + }, + paths: { + '/vehicle/{id}/owner': { + get: { + operationId: 'getOwnerOfVehicleById', + summary: 'Get the owner of a specific vehicle.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Person' + } + } + } + } + } + } + } + }, + components: { + schemas: { + Person: { + // @ts-ignore + 'x-graphql-prefix-schema-with': 'Owner', + type: 'object', + properties: { + id: { + type: 'integer' + } + } + } + } + } +} +const mockSwaggerWithHATEOASLinks: Spec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '0.0.1' + }, + paths: { + '/vehicles': { + get: { + operationId: 'getVehicles', + summary: 'Get all vehicles.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Vehicles' + } + } + } + } + } + } + } + }, + components: { + schemas: { + Vehicles: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Vehicle' + } + } + } + }, + Vehicle: { + type: 'object', + properties: { + id: { + type: 'integer' + }, + _links: { + $ref: '#/components/schemas/VehicleLinks' + } + } + }, + VehicleLinks: { + type: 'object', + properties: { + owner: { + $ref: '#/components/schemas/XLink' + } + }, + // @ts-ignore + 'x-links': [ + { + rel: 'owner', + type: 'application/json', + hrefPattern: '/vehicle/{id}/owner' + } + ] + }, + XLink: { + type: 'object', + properties: { + type: { + type: 'string' + }, + href: { + type: 'string' + } + } + }, + Car: { + allOf: [ + { + $ref: '#/components/schemas/Vehicle' + }, + { + type: 'object', + properties: { + fuelType: { + type: 'string' + } + } + } + ] + }, + Bike: { + allOf: [ + { + $ref: '#/components/schemas/Vehicle' + }, + { + type: 'object', + properties: { + bikeType: { + type: 'string' + } + } + } + ] + } + } + } +} +const mockSwaggerWithHATEOASLinksAndVersionedTypes: Spec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '5.0.0' + }, + paths: { + '/vehicles': { + get: { + operationId: 'getVehicles', + summary: 'Get all vehicles.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Vehicles_v5' + } + } + } + } + } + } + } + }, + components: { + schemas: { + Vehicles_v5: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Vehicle_v5' + } + } + } + }, + Vehicle_v5: { + type: 'object', + properties: { + id: { + type: 'integer' + }, + _links: { + $ref: '#/components/schemas/VehicleLinks_v5' + } + } + }, + VehicleLinks_v5: { + type: 'object', + properties: { + owner: { + $ref: '#/components/schemas/XLink_v5' + } + }, + // @ts-ignore + 'x-links': [ + { + rel: 'owner', + type: 'application/json', + hrefPattern: '/vehicle/{id}/owner' + } + ] + }, + XLink_v5: { + type: 'object', + properties: { + type: { + type: 'string' + }, + href: { + type: 'string' + } + } + } + } + } +} +const mockAvailableTypes = ['Vehicles', 'Vehicle', 'VehicleLinks', 'Car', 'Bike', 'Person'] +const mockAvailableTypesWithVersions = [ + 'Vehicles_v5', + 'Vehicle_v5', + 'VehicleLinks_v5', + 'Person_v3', + 'Person_v2', + 'Person_v1' +] +const mockInterfacesWithChildren = { Vehicle: ['Car', 'Bike'] } +const mockCatalog: Catalog = { + '/vehicle/{id}/owner': { + operationIds: ['checkOwner'], + type: 'Person', + swaggers: ['checkOwner.json'] + } +} + +/** + * ---------- TESTS ---------- + */ + +// Empty Swagger +describe('Empty Swagger test', () => { + it('should return empty typeDefs and empty resolvers', () => { + const result: ConfigExtension = generateTypeDefsAndResolversFromSwagger( + { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '0.0.1' + }, + paths: {} + }, + mockAvailableTypes, + mockInterfacesWithChildren, + mockCatalog, + {} + ) + + // typeDefs + expect(result.typeDefs).toStrictEqual('') + // resolvers + expect(result.resolvers).toStrictEqual({}) + }) +}) + +// Schema prefixing +describe('Schema prefixing test', () => { + it('should return the correct typeDefs and resolvers', () => { + const result: ConfigExtension = generateTypeDefsAndResolversFromSwagger( + mockSwaggerWithPrefix, + mockAvailableTypes, + mockInterfacesWithChildren, + mockCatalog, + {} + ) + + // typeDefs + expect(result).toHaveProperty('typeDefs') + expect(result.typeDefs).toBe( + /** + * extend type Person @prefixSchema(prefix: "Owner") { + * dummy: String + * } + */ + 'extend type Person @prefixSchema(prefix: "Owner") { dummy: String }\n' + ) + // resolvers + expect(result.resolvers).toStrictEqual({}) + }) +}) + +// Simple HATEOAS link generation +describe('Simple HATEOAS link generation test', () => { + it('should return the correct typeDefs and resolvers', () => { + const result: ConfigExtension = generateTypeDefsAndResolversFromSwagger( + mockSwaggerWithHATEOASLinks, + mockAvailableTypes, + {}, + mockCatalog, + {} + ) + + // typeDefs + expect(result).toHaveProperty('typeDefs') + expect(result.typeDefs).toBe( + /** + * extend type Vehicle { + * owner: Person + * _linksList: [LinkItem] + * } + */ + 'extend type Vehicle {\nowner: Person\n_linksList: [LinkItem]\n}\n' + ) + // resolvers + expect(result).toHaveProperty('resolvers') + expect(result.resolvers).toHaveProperty('Vehicle') + expect(result.resolvers['Vehicle']).toHaveProperty('owner') + expect(result.resolvers['Vehicle']).toHaveProperty('_linksList') + }) +}) + +// HATEOAS link generation with interfaces +describe('HATEOAS link generation with interfaces test', () => { + it('should return the correct typeDefs and resolvers', () => { + const result: ConfigExtension = generateTypeDefsAndResolversFromSwagger( + mockSwaggerWithHATEOASLinks, + mockAvailableTypes, + mockInterfacesWithChildren, + mockCatalog, + {} + ) + + // typeDefs + expect(result).toHaveProperty('typeDefs') + expect(result.typeDefs).toBe( + /** + * extend interface Vehicle { + * owner: Person + * _linksList: [LinkItem] + * } + * extend type Car { + * owner: Person + * _linksList: [LinkItem] + * } + * extend type Bike { + * owner: Person + * _linksList: [LinkItem] + * } + */ + 'extend interface Vehicle {\nowner: Person\n_linksList: [LinkItem]\n}\nextend type Car {\nowner: Person\n_linksList: [LinkItem]\n}\nextend type Bike {\nowner: Person\n_linksList: [LinkItem]\n}\n' + ) + // resolvers + expect(result).toHaveProperty('resolvers') + expect(result.resolvers).toHaveProperty('Vehicle') + expect(result.resolvers['Vehicle']).toHaveProperty('owner') + expect(result.resolvers['Vehicle']).toHaveProperty('_linksList') + expect(result.resolvers['Vehicle']).toHaveProperty('__resolveType') + expect(result.resolvers).toHaveProperty('Car') + expect(result.resolvers['Car']).toHaveProperty('owner') + expect(result.resolvers['Car']).toHaveProperty('_linksList') + expect(result.resolvers).toHaveProperty('Bike') + expect(result.resolvers['Bike']).toHaveProperty('owner') + expect(result.resolvers['Bike']).toHaveProperty('_linksList') + }) +}) + +// HATEOAS link generation with versions +describe('HATEOAS link generation with versions test', () => { + it('should return the correct typeDefs and resolvers', () => { + const result: ConfigExtension = generateTypeDefsAndResolversFromSwagger( + mockSwaggerWithHATEOASLinksAndVersionedTypes, + mockAvailableTypesWithVersions, + {}, + mockCatalog, + {} + ) + + // typeDefs + expect(result).toHaveProperty('typeDefs') + expect(result.typeDefs).toBe( + /** + * extend type Vehicle_v5 { + * owner_v3: Person_v3 + * owner_v2: Person_v2 + * owner_v1: Person_v1 + * _linksList: [LinkItem] + * } + */ + 'extend type Vehicle_v5 {\nowner_v3: Person_v3\nowner_v2: Person_v2\nowner_v1: Person_v1\n_linksList: [LinkItem]\n}\n' + ) + // resolvers + expect(result).toHaveProperty('resolvers') + expect(result.resolvers).toHaveProperty('Vehicle_v5') + expect(result.resolvers['Vehicle_v5']).toHaveProperty('owner_v3') + expect(result.resolvers['Vehicle_v5']).toHaveProperty('owner_v2') + expect(result.resolvers['Vehicle_v5']).toHaveProperty('owner_v1') + expect(result.resolvers['Vehicle_v5']).toHaveProperty('_linksList') + }) +}) diff --git a/packages/graphql-mesh/utils/additionalResolvers.ts b/packages/graphql-mesh/utils/additionalResolvers.ts index 0da88b3..0d7a5fb 100644 --- a/packages/graphql-mesh/utils/additionalResolvers.ts +++ b/packages/graphql-mesh/utils/additionalResolvers.ts @@ -1,4 +1,4 @@ -import ConfigFromSwaggers from './ConfigFromSwaggers' +import ConfigFromSwaggers from './configFromSwaggers' const configFromSwaggers = new ConfigFromSwaggers() const { additionalResolvers } = configFromSwaggers.getMeshConfigFromSwaggers() diff --git a/packages/graphql-mesh/utils/ConfigFromSwaggers.ts b/packages/graphql-mesh/utils/configFromSwaggers.ts similarity index 100% rename from packages/graphql-mesh/utils/ConfigFromSwaggers.ts rename to packages/graphql-mesh/utils/configFromSwaggers.ts diff --git a/packages/graphql-mesh/utils/generateTypeDefsAndResolvers.ts b/packages/graphql-mesh/utils/generateTypeDefsAndResolvers.ts index 54d5dc3..f6ca2bc 100755 --- a/packages/graphql-mesh/utils/generateTypeDefsAndResolvers.ts +++ b/packages/graphql-mesh/utils/generateTypeDefsAndResolvers.ts @@ -54,7 +54,7 @@ export const generateTypeDefsAndResolversFromSwagger = ( // Add a prefixSchema directive to the type definition typeDefs += `extend ${schemaType} ${schemaKey} @prefixSchema(prefix: "${value}") { dummy: String }\n` - // If it's an interface, prefix each of its children too + // If it's an interface, add the dummy property to each of its children too if (schemaType === 'interface') { interfacesWithChildren[schemaKey].forEach((children) => { const parentVersion = schemaKey.split('_')[schemaKey.split('_').length - 1]