Skip to content

Commit

Permalink
Merge pull request #13 from nrccua/fix/missing_path_params
Browse files Browse the repository at this point in the history
[E4E-87]: Update to fix missing path params when a body is specified
  • Loading branch information
gsimionato-daitan authored Jan 7, 2022
2 parents 155b717 + cbda2b6 commit 9b72b48
Show file tree
Hide file tree
Showing 9 changed files with 1,705 additions and 826 deletions.
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
build
dist
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## [1.3.0](https://github.com/nrccua/apollo-rest-utils/compare/1.2.2...1.3.0) (2022-01-06)


### Changes

* [E4E-87]: Update to fix missing path params when a body is specified ([655fd4c](https://github.com/nrccua/apollo-rest-utils/commit/655fd4c3ae23df53d4e0bc1dfd3a4d9c7cf6d3fd))
* Merge pull request #12 from nrccua/feature/make_cross_node_compatible ([155b717](https://github.com/nrccua/apollo-rest-utils/commit/155b717ba0f3bb02f750d40723ab479a9fe81cac)), closes [#12](https://github.com/nrccua/apollo-rest-utils/issues/12)
* [E4E-0]: 1.2.2 ([749db93](https://github.com/nrccua/apollo-rest-utils/commit/749db93999d6d7e9e9840abfcc311061ac700fdd))
* Merge pull request #11 from nrccua/feature/rest_type_helpers ([d0335a5](https://github.com/nrccua/apollo-rest-utils/commit/d0335a5becaa52a722570103cfd5aed36041b973)), closes [#11](https://github.com/nrccua/apollo-rest-utils/issues/11)

### [1.2.2](https://github.com/nrccua/apollo-rest-utils/compare/1.2.1...1.2.2) (2021-12-09)


Expand Down
19 changes: 11 additions & 8 deletions lib/generateRoutes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,22 @@ describe('generateTypescript', () => {
deleteBuildFolder();
});

it.each(fs.readdirSync(testSwaggerPath).map(d => path.join(testSwaggerPath, d)))('can parse the swagger document %s', async apiPath => {
const api = await SwaggerParser.validate(apiPath);
it.each(fs.readdirSync(testSwaggerPath).map(d => path.join(testSwaggerPath, d)))(
'can parse the swagger document %s',
async apiPath => {
const api = await SwaggerParser.validate(apiPath);

const typeImport = await generateTypes(apiPath, path.join(buildFolder, `${randomUUID()}_types.ts`));
const typeImport = await generateTypes(apiPath, path.join(buildFolder, `${randomUUID()}_types.ts`));

const tsData = generateTypescript(api, typeImport).replace(/apollo-rest-utils/g, '../../lib');
const tsData = generateTypescript(api, typeImport).replace(/apollo-rest-utils/g, '../../lib');

expect(tsData).toBeTruthy(); // Not empty, null, or undefined
expect(tsData).toBeTruthy(); // Not empty, null, or undefined

expect(tsData.includes(', endpoint: "')).toBe(false);
expect(tsData.includes(', endpoint: "')).toBe(false);

doTestImport(tsData);
});
doTestImport(tsData);
},
);

it('can generate endpoints with the optional endpoint id', async () => {
const apiPath = path.join(testSwaggerPath, 'OpenAPIV3WithRef.json');
Expand Down
72 changes: 61 additions & 11 deletions lib/generateRoutes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import path from 'path';
import SwaggerParser from '@apidevtools/swagger-parser';
import _, { first } from 'lodash';
import { OpenAPI, OpenAPIV3 } from 'openapi-types';
import openapiTS, { ReferenceObject, ResponseObject, SchemaObject } from 'openapi-typescript';
import openapiTS, { ParameterObject, ReferenceObject, ResponseObject, SchemaObject } from 'openapi-typescript';
import prettier from 'prettier';

import { RestEndpointSchema } from '../types';
Expand All @@ -37,7 +37,11 @@ export function getResponseSchema(properties?: {
'properties' in properties[p]
? { [p]: getResponseSchema((properties[p] as OpenAPIV3.SchemaObject).properties) } // eslint-disable-this-line @typescript-eslint/no-unsafe-assignment
: 'items' in properties[p]
? { [p]: getResponseSchema(((properties[p] as OpenAPIV3.ArraySchemaObject).items as OpenAPIV3.SchemaObject).properties) }
? {
[p]: getResponseSchema(
((properties[p] as OpenAPIV3.ArraySchemaObject).items as OpenAPIV3.SchemaObject).properties,
),
}
: p,
)
.filter(p =>
Expand All @@ -60,6 +64,36 @@ export function pathToType(endpointPath: string, isArray = false): string {
return `${isArray ? '[' : ''}${result.replace(result[0], result[0].toUpperCase())}${isArray ? ']' : ''}`;
}

export function typeOfParam(param: ParameterObject): string {
return param.schema &&
'type' in param.schema &&
param.schema?.type &&
['number', 'string'].includes(param.schema?.type)
? param.schema?.type
: 'any';
}

export function typeObjectFromParams(
params: (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[],
): string | undefined {
if (params.length === 0) {
return undefined;
}

let typeString = '{ ';

params.forEach(p => {
const paramObject = p as ParameterObject;
if (paramObject.name) {
typeString += `${_.camelCase(paramObject.name)}: ${typeOfParam(paramObject)}; `;
}
});

typeString += ' }';

return typeString;
}

export async function generateTypes(apiPath: string, filePath: string): Promise<string> {
const typeOutput = await openapiTS(apiPath, { prettierConfig: '.prettierrc.js' });
fs.writeFileSync(filePath, typeOutput);
Expand Down Expand Up @@ -101,7 +135,9 @@ export function generateTypescript(api: OpenAPI.Document, typeImportLocation: st
responseBody += `['content']`;
if (responseObject?.content?.['application/json']) {
responseBody += `['application/json']`;
schema = responseObject.content['application/json'].schema as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject;
schema = responseObject.content['application/json'].schema as
| OpenAPIV3.ReferenceObject
| OpenAPIV3.SchemaObject;
}
} else if ('schema' in responseObject) {
responseBody += `['schema']`;
Expand All @@ -117,28 +153,42 @@ export function generateTypescript(api: OpenAPI.Document, typeImportLocation: st
}
const requestBodyObject = endpointObject.requestBody as OpenAPIV3.RequestBodyObject;
const contentKeys = Object.keys(requestBodyObject?.content ?? {});
const queryPathParams =
endpointObject.parameters?.filter(p => 'in' in p && ['path', 'query'].includes(p.in)) ?? [];
const queryPathParamsObject = typeObjectFromParams(queryPathParams);
const bodyParam = first(endpointObject.parameters?.filter(p => 'name' in p && p.name.toLowerCase() === 'body'));
// eslint-disable-next-line no-nested-ternary
const requestBody = contentKeys.includes('application/json')
? // OpenAPI 3 way
`paths['${endpointPath}']['${method}']['requestBody']['content']['application/json']`
`paths['${endpointPath}']['${method}']['requestBody']['content']['application/json']${
queryPathParamsObject ? ` & ${queryPathParamsObject}` : ''
}`
: // Swagger 2 way
bodyParam
? `paths['${endpointPath}']['${method}']['parameters']['body']['body']`
: undefined;
const headers = endpointObject.parameters?.filter(p => 'in' in p && p.in === 'header').map(p => p as OpenAPIV3.ParameterObject) ?? [];
? `paths['${endpointPath}']['${method}']['parameters']['body']['body']${
queryPathParamsObject ? ` & ${queryPathParamsObject}` : ''
}`
: queryPathParamsObject;
const headers =
endpointObject.parameters
?.filter(p => 'in' in p && p.in === 'header')
.map(p => p as OpenAPIV3.ParameterObject) ?? [];
routes[method as Uppercase<OpenAPIV3.HttpMethods>][
normalizeName(endpointPath)
] = `${`{ gql: '@rest(method: "${method.toUpperCase()}", path: "${addArgsToPath(
endpointPath,
endpointObject.parameters as OpenAPIV3.ParameterObject[] | undefined,
)}", type: "${pathToType(endpointPath, isArray)}"${endpointId ? `, endpoint: "${endpointId}"` : ''})', `}${
headers?.length > 0
? `headers: [${headers.map(h => JSON.stringify({ description: h.description, name: h.name, required: h.required, schema: h.schema })).join(',\n')}],`
? `headers: [${headers
.map(h =>
JSON.stringify({ description: h.description, name: h.name, required: h.required, schema: h.schema }),
)
.join(',\n')}],`
: ''
}${responseSchema ? `responseSchema: ${JSON.stringify(responseSchema)},\n` : ''}} as IRestEndpoint<${responseBody}${
requestBody ? `,${requestBody}` : ''
}>,`;
}${
responseSchema ? `responseSchema: ${JSON.stringify(responseSchema)},\n` : ''
}} as IRestEndpoint<${responseBody}${requestBody ? `,${requestBody}` : ''}>,`;
});
});
generatedTSEndpoints += 'const ROUTES = {\n';
Expand Down
54 changes: 40 additions & 14 deletions lib/useRestQuery/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ jest.mock('@apollo/client', () => ({
describe('useRestQuery Library', () => {
const useMutationMock = useMutation as jest.Mock;
const useQueryMock = useQuery as jest.Mock;
const dummyEndpoint = { gql: '@rest(method: "get", path: "test")' } as IRestEndpoint<{ sessionToken: string }, { testInput: string }>;
const dummyEndpoint = { gql: '@rest(method: "get", path: "test")' } as IRestEndpoint<
{ sessionToken: string },
{ testInput: string }
>;

beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -131,18 +134,19 @@ describe('useRestQuery Library', () => {
expect(data?.refreshToken.sessionToken).toBe(testToken);

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const generatedNode = (first(first(clientMock.query.mock.calls)) as Parameters<typeof wrappedRestQuery>[0])?.query as DocumentNode;
const generatedNode = (first(first(clientMock.query.mock.calls)) as Parameters<typeof wrappedRestQuery>[0])
?.query as DocumentNode;

// Make sure our @rest gql got injected
expect(print(generatedNode)).toContain(dummyEndpoint.gql);
});
});

describe('validateQueryAgainstEndpoint', () => {
const dummyEndpoint = { gql: '@rest(method: "get", path: "test")', responseSchema: ['sessionToken'] } as IRestEndpoint<
{ sessionToken: string },
{ testInput: string }
>;
const dummyEndpoint = {
gql: '@rest(method: "get", path: "test")',
responseSchema: ['sessionToken'],
} as IRestEndpoint<{ sessionToken: string }, { testInput: string }>;

beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -180,22 +184,37 @@ describe('validateQueryAgainstEndpoint', () => {
it('should throw an error for a query with no definitions', () => {
const query = { definitions: [], kind: 'Document' } as DocumentNode;

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError('Query must contain exactly one definition');
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError(
'Query must contain exactly one definition',
);
});

it('should throw an error for a query with a non-operation definitions', () => {
const query = { definitions: [{ kind: 'ScalarTypeDefinition', name: { kind: 'Name', value: 'NULL' } }], kind: 'Document' } as DocumentNode;
const query = {
definitions: [{ kind: 'ScalarTypeDefinition', name: { kind: 'Name', value: 'NULL' } }],
kind: 'Document',
} as DocumentNode;

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError('Query definition must be an operation');
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError(
'Query definition must be an operation',
);
});

it('should throw and error with an empty selections array', () => {
const query: DocumentNode = {
definitions: [{ kind: 'OperationDefinition', operation: 'query', selectionSet: { kind: 'SelectionSet', selections: [] as readonly SelectionNode[] } }],
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
selectionSet: { kind: 'SelectionSet', selections: [] as readonly SelectionNode[] },
},
],
kind: 'Document',
};

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError('Query must contain exactly one selection');
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError(
'Query must contain exactly one selection',
);
});

it('should throw and error with a non-Field selection', () => {
Expand All @@ -204,7 +223,10 @@ describe('validateQueryAgainstEndpoint', () => {
{
kind: 'OperationDefinition',
operation: 'query',
selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'NULL' } }] as readonly SelectionNode[] },
selectionSet: {
kind: 'SelectionSet',
selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'NULL' } }] as readonly SelectionNode[],
},
},
],
kind: 'Document',
Expand All @@ -220,7 +242,9 @@ describe('validateQueryAgainstEndpoint', () => {
}
`;

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError('Query selection must contain at least one value to return');
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError(
'Query selection must contain at least one value to return',
);
});

it('should throw an error for a query with a bad field', () => {
Expand All @@ -232,6 +256,8 @@ describe('validateQueryAgainstEndpoint', () => {
}
`;

expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError('Query contains invalid fields: test');
expect(() => validateQueryAgainstEndpoint(query, dummyEndpoint)).toThrowError(
'Query contains invalid fields: test',
);
});
});
53 changes: 40 additions & 13 deletions lib/useRestQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ export function validateQueryAgainstEndpoint<TName extends string, TData = unkno
});

if (!(selectionsIncludeHeaders && definition.selectionSet.selections.length === 2)) {
throw new InvalidQueryError('Query must contain exactly one selection, or one selection with headers (if using the HeadersLink)', query, endpoint);
throw new InvalidQueryError(
'Query must contain exactly one selection, or one selection with headers (if using the HeadersLink)',
query,
endpoint,
);
}
}

Expand All @@ -57,10 +61,18 @@ export function validateQueryAgainstEndpoint<TName extends string, TData = unkno

const subFields = selection.selectionSet.selections.map(s => (s as FieldNode).name.value);

const badFields = subFields.filter(fieldName => endpoint.responseSchema !== undefined && getSchemaField(endpoint.responseSchema, fieldName) === undefined);
const badFields = subFields.filter(
fieldName =>
endpoint.responseSchema !== undefined && getSchemaField(endpoint.responseSchema, fieldName) === undefined,
);

if (badFields.length > 0) {
throw new InvalidQueryError(`Query contains invalid fields: ${badFields.join(', ')}`, query, endpoint, endpoint.responseSchema);
throw new InvalidQueryError(
`Query contains invalid fields: ${badFields.join(', ')}`,
query,
endpoint,
endpoint.responseSchema,
);
}
}

Expand All @@ -77,10 +89,12 @@ export function useRestMutation<
MutationHookOptions<NamedGQLResult<TName, TData>, TVariables, TContext>,
): MutationTuple<NamedGQLResult<TName, TData>, TVariables | Input<TVariables>, TContext, TCache> {
validateQueryAgainstEndpoint(mutation, options.endpoint);
const directives = (mutation.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const directives = (mutation.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
if (directives.length === 0) {
const dummyGQL = gql`query a($c: any) { b(c: $c) ${options.endpoint.gql} {d} }`;
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
directives.push(dummyDirectives[0]);
}

Expand Down Expand Up @@ -119,8 +133,13 @@ export function useRestMutation<
*` const uid = result.user.uid; // This is properly typed!`
*/
export function wrapRestMutation<TName extends string>() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <TData = unknown, TVariables = OperationVariables, TContext = DefaultContext, TCache extends ApolloCache<any> = ApolloCache<any>>(
return <
TData = unknown,
TVariables = OperationVariables,
TContext = DefaultContext,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TCache extends ApolloCache<any> = ApolloCache<any>,
>(
mutation: DocumentNode | TypedDocumentNode<TData, TVariables>,
options: IEndpointOptions<TData, TVariables> & MutationHookOptions<TData, TVariables, TContext>,
): MutationTuple<NamedGQLResult<TName, TData>, TVariables | Input<TVariables>, TContext, TCache> =>
Expand All @@ -133,18 +152,24 @@ export function wrapRestMutation<TName extends string>() {

export function useRestQuery<TName extends string, TData, TVariables>(
query: DocumentNode | TypedDocumentNode<NamedGQLResult<TName, TData>, TVariables>,
options: IEndpointOptions<NamedGQLResult<TName, TData>, TVariables | Input<TVariables>> & QueryHookOptions<NamedGQLResult<TName, TData>, TVariables>,
options: IEndpointOptions<NamedGQLResult<TName, TData>, TVariables | Input<TVariables>> &
QueryHookOptions<NamedGQLResult<TName, TData>, TVariables>,
): QueryResult<NamedGQLResult<TName, TData>, TVariables> {
validateQueryAgainstEndpoint(query, options.endpoint);
const directives = (query.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const directives = (query.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
if (directives.length === 0) {
const dummyGQL = gql`query a($c: any) { b(c: $c) ${options.endpoint.gql} {d} }`;
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
directives.push(dummyDirectives[0]);
}

// eslint-disable-next-line react-hooks/rules-of-hooks
return useQuery<NamedGQLResult<TName, TData>, TVariables>(query, options as QueryHookOptions<NamedGQLResult<TName, TData>, TVariables>);
return useQuery<NamedGQLResult<TName, TData>, TVariables>(
query,
options as QueryHookOptions<NamedGQLResult<TName, TData>, TVariables>,
);
}

/**
Expand Down Expand Up @@ -191,10 +216,12 @@ export function useRestClientQuery<TName extends string, TData, TVariables>(
QueryOptions<TVariables, NamedGQLResult<TName, TData>> & { client: ApolloClient<object> },
): Promise<ApolloQueryResult<NamedGQLResult<TName, TData>>> {
validateQueryAgainstEndpoint(options.query, options.endpoint);
const directives = (options.query.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const directives = (options.query.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
if (directives.length === 0) {
const dummyGQL = gql`query a($c: any) { b(c: $c) ${options.endpoint.gql} {d} }`;
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0].directives as DirectiveNode[];
const dummyDirectives = (dummyGQL.definitions[0] as OperationDefinitionNode).selectionSet.selections[0]
.directives as DirectiveNode[];
directives.push(dummyDirectives[0]);
}

Expand Down
Loading

0 comments on commit 9b72b48

Please sign in to comment.