diff --git a/package.json b/package.json index 8cbef05..ab4f497 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thoughtspot/rise", - "version": "0.7.17", + "version": "0.7.18", "description": "Rise above the REST with GraphQL", "main": "dist/index.js", "scripts": { diff --git a/src/common.ts b/src/common.ts index d194ad8..cfb3d3c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,3 +1,9 @@ +import { + FieldNode, + parse, + visit, + print, +} from 'graphql'; import _ from 'lodash'; const FORWARD_RESPONSE_HEADERS = [ @@ -35,19 +41,19 @@ export function getReqHeaders(riseDirective: RiseDirectiveOptions, options, cont headers = {}, contenttype = options.contenttype || 'application/json', forwardheaders = [], - } = riseDirective; + } = riseDirective; - headers = { + headers = { 'Content-Type': contenttype, ...options.headers, ...headers, - }; - forwardheaders.push(...options.forwardheaders); - forwardheaders = forwardheaders.map((h) => h.toLowerCase()); - return { + }; + forwardheaders.push(...options.forwardheaders); + forwardheaders = forwardheaders.map((h) => h.toLowerCase()); + return { ...headers, ..._.pickBy(context.req.headers, (v, h) => forwardheaders.includes(h.toLowerCase())), - }; + }; } export function processResHeaders(response, context) { @@ -71,3 +77,91 @@ export class RestError extends Error { this.errors = errors; } } + +// The function is used to map the keys of the object to the new keys +// By default, it will return the original object if the key is not in the keyMap +// Example: +// const obj = { a: 1, b: 2, c: 3 } +// const keyMap = { a: 'd', b: 'e', f: 'g' } +// const newObj = mapKeysDeep(obj, keyMap) +// newObj will be { d: 1, e: 2, c: 3 } +// The function will not modify the original object +export function mapKeysDeep(obj: any, keyMap: Record = {}): any { + // If keyMap is empty, return original object + if (!keyMap || Object.keys(keyMap).length === 0) { + return obj; + } + // If obj is an array, map each item in the array + if (Array.isArray(obj)) { + return obj.map((item) => mapKeysDeep(item, keyMap)); + } + // If obj is an object, map each key in the object + if (obj !== null && typeof obj === 'object') { + const mapped: Record = {}; + Object.keys(obj).forEach((key) => { + const newKey = keyMap[key] || key; + mapped[newKey] = mapKeysDeep(obj[key], keyMap); + }); + return mapped; + } + return obj; +} + +/** + * Parses responseKeyFormat from string to object format + * @param responseKeyFormat - The response key format, can be string (JSON) or object + * @returns Parsed key mapping object or empty object if parsing fails + */ +export function parseResponseKeyFormat( + responseKeyFormat: string | Record | undefined, +): Record { + if (!responseKeyFormat) { + return {}; + } + // If it's already an object, return it directly + if (typeof responseKeyFormat === 'object') { + return responseKeyFormat; + } + // If it's a string, try to parse it as JSON + if (typeof responseKeyFormat === 'string') { + try { + const parsed = JSON.parse(responseKeyFormat); + console.debug('[Rise] Parsed responseKeyFormat:', parsed); + return parsed; + } catch (error) { + console.error('[Rise] Failed to parse responseKeyFormat as JSON:', error); + console.error('[Rise] Invalid responseKeyFormat:', responseKeyFormat); + return {}; + } + } + return {}; +} + +export function reverseKeyValue(obj: Record): Record { + const reversed: Record = {}; + Object.keys(obj).forEach((key) => { + reversed[String(obj[key])] = key; + }); + return reversed; +} + +export function renameFieldsInQuery(query: string, renameMap: { [key: string]: string }): string { + const ast = parse(query); + const updatedAst = visit(ast, { + Field(node: FieldNode): FieldNode | undefined { + const newName = renameMap[node.name.value]; + if (newName) { + return { + ...node, + name: { + ...node.name, + value: newName, + }, + }; + } + return undefined; + }, + }); + console.debug('[Rise] Renamed fields in query:', updatedAst); + return print(updatedAst); +} diff --git a/src/gql-resolver.ts b/src/gql-resolver.ts index 92b4ac4..46a8d17 100644 --- a/src/gql-resolver.ts +++ b/src/gql-resolver.ts @@ -2,7 +2,15 @@ import fetch from 'node-fetch'; import { GraphQLFieldConfig } from 'graphql'; import { print } from 'graphql/language/printer'; import _ from 'lodash'; -import { RiseDirectiveOptions, getReqHeaders, processResHeaders } from './common'; +import { + RiseDirectiveOptions, + getReqHeaders, + mapKeysDeep, + parseResponseKeyFormat, + processResHeaders, + renameFieldsInQuery, + reverseKeyValue, +} from './common'; import { generateBodyFromTemplate } from './rest-resolver'; export interface RiseDirectiveOptionsGql extends RiseDirectiveOptions { @@ -76,7 +84,11 @@ export function gqlResolver( fieldConfig: GraphQLFieldConfig, ) { const url = options.baseURL; - let { argwrapper, gqlVariables } = riseDirective; + let { argwrapper, gqlVariables, responseKeyFormat } = riseDirective; + + console.debug('[Rise] GQL - Response key format', responseKeyFormat); + const keyMap = parseResponseKeyFormat(responseKeyFormat); + const reverseKeyMap = reverseKeyValue(keyMap); fieldConfig.resolve = (source, args, context, info) => { let urlToFetch = url; @@ -89,7 +101,15 @@ export function gqlResolver( query = wrapArgumentsInGql(query, info, argwrapper); } - const variables = gqlVariables ? generateBodyFromTemplate(gqlVariables, args) : info.variableValues; + const variables = gqlVariables + ? generateBodyFromTemplate(gqlVariables, args) + : info.variableValues; + console.debug('[Rise] GQL - Variables', variables); + if (Object.keys(reverseKeyMap).length > 0) { + query = renameFieldsInQuery(query, reverseKeyMap); + } + console.debug('[Rise] GQL - Query', query); + let body = JSON.stringify({ query, variables: wrappingObject ? { [wrappingObject]: variables } : variables, @@ -117,8 +137,9 @@ export function gqlResolver( response.errors, ); } - - return response.data[info.fieldName]; - }); + return response; + }) + .then((data) => mapKeysDeep(data, keyMap)) + .then((response) => response.data[info.fieldName]); }; } diff --git a/src/index.ts b/src/index.ts index ae0a92e..8bebe23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ export const getGqlRiseDirectiveTypeDefs = (name: string) => ` name: String! type: String! } - directive @${name}(argwrapper: RiseGQLArgWrapper, gqlVariables: String) on FIELD_DEFINITION + directive @${name}(argwrapper: RiseGQLArgWrapper, gqlVariables: String, responseKeyFormat: String) on FIELD_DEFINITION `; type RiseDirectiveOptions = RiseDirectiveOptionsRest | RiseDirectiveOptionsGql; diff --git a/test/index.ts b/test/index.ts index dcb7656..95694f7 100644 --- a/test/index.ts +++ b/test/index.ts @@ -7,6 +7,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import fs from 'fs'; import path from 'path'; import { rise } from '../src/index'; +import { mapKeysDeep, parseResponseKeyFormat, reverseKeyValue, renameFieldsInQuery } from '../src/common'; const typeDefs = fs.readFileSync(path.join(__dirname, './schema.graphql'), 'utf8'); @@ -51,6 +52,102 @@ afterAll(() => { const a = function () { return {}; }; + +describe('mapKeysDeep', () => { + test('should return the original object if keyMap is empty', () => { + const input = { firstName: 'John', lastName: 'Doe', age: 30 }; + const keyMap = {}; + const expected = { firstName: 'John', lastName: 'Doe', age: 30 }; + expect(mapKeysDeep(input, keyMap)).toEqual(expected); + }); + + test('should map keys according to keyMap', () => { + const input = { firstName: 'John', lastName: 'Doe', age: 30 }; + const keyMap = { firstName: 'first_name', lastName: 'last_name' }; + const expected = { first_name: 'John', last_name: 'Doe', age: 30 }; + expect(mapKeysDeep(input, keyMap)).toEqual(expected); + }); + + test('should map keys in nested objects and arrays', () => { + const input = { + users: [ + { + firstName: 'John', + contact: { + phoneNumber: '123-456-7890' + } + }, + { + firstName: 'Jane', + contact: { + phoneNumber: '987-654-3210' + } + } + ] + }; + const keyMap = { + firstName: 'first_name', + phoneNumber: 'phone_number' + }; + const expected = { + users: [ + { + first_name: 'John', + contact: { + phone_number: '123-456-7890' + } + }, + { + first_name: 'Jane', + contact: { + phone_number: '987-654-3210' + } + } + ] + }; + + expect(mapKeysDeep(input, keyMap)).toEqual(expected); + }); +}); + +describe('parseResponseKeyFormat', () => { + test('empty cases', () => { + const result = parseResponseKeyFormat(''); + expect(result).toEqual({}); + + const result2 = parseResponseKeyFormat(null as any); + expect(result2).toEqual({}); + + const result3 = parseResponseKeyFormat(undefined); + expect(result3).toEqual({}); + }); + + test('should parse complex nested JSON string correctly', () => { + const jsonString = '{"user": {"firstName": "first_name"}, "contact": {"phoneNumber": "phone_number"}}'; + const expected = { + user: { firstName: 'first_name' }, + contact: { phoneNumber: 'phone_number' } + }; + const result = parseResponseKeyFormat(jsonString); + expect(result).toEqual(expected); + + const jsonString2 = '{"user-name": "user_name", "email@domain": "email_domain"}'; + const expected2 = { 'user-name': 'user_name', 'email@domain': 'email_domain' }; + const result2 = parseResponseKeyFormat(jsonString2); + expect(result2).toEqual(expected2); + }); + + test('should return empty object for malformed JSON string', () => { + const malformedJson = 'not a json string'; + const result = parseResponseKeyFormat(malformedJson); + expect(result).toEqual({}); + + const syntaxErrorJson = '{"firstName" "first_name"}'; // Missing colon + const result2 = parseResponseKeyFormat(syntaxErrorJson); + expect(result2).toEqual({}); + }); +}); + describe('Should call the Target', () => { test('with the correct headers', async () => { nock('https://rise.com/callosum/v1', { @@ -664,6 +761,56 @@ describe('Should handle gql type', () => { }); }); + test('when gql query with responseKeyFormat is executed should return data as expected', () => { + nock(GQL_BASE_URL, { + }) + .post('') + .reply(200, (...args) => ({ + data: { + getGQLSessionDetailsWithResponseKeyFormat: { + id: '123', + name: 'John', + email: 'john@doe.com', + extra: 'extra', + }, + }, + })); + + const contextValue = { + req: { + headers: { + Authorization: 'Bearer 123', + cookie: 'a=a', + foo: 'bar', + }, + }, + }; + + return graphql({ + schema, + source: ` + query getSession($sessionId: String, $asd: String) { + getGQLSessionDetailsWithResponseKeyFormat(sessionId: $sessionId, asd: $asd) { + name_test + email + id + } + } + `, + contextValue, + variableValues: { + sessionId: '1234', + asd: 'abc' + } + }).then((response: any) => { + expect(response?.data?.getGQLSessionDetailsWithResponseKeyFormat).toBeDefined(); + expect(response?.data?.getGQLSessionDetailsWithResponseKeyFormat).toMatchObject({ + name_test: 'John', + id: '123', + }); + }); + }); + test('when there is a error in gql query, the error should be responded back', () => { nock(GQL_BASE_URL, { }) @@ -879,3 +1026,108 @@ describe('Parse data according to the content type', () => { }); }); }); + +describe('reverseKeyValue', () => { + test('should reverse key-value pairs with string values', () => { + const input = { name: 'John', city: 'NYC', country: 'USA' }; + const expected = { John: 'name', NYC: 'city', USA: 'country' }; + const result = reverseKeyValue(input); + expect(result).toEqual(expected); + }); + + test('should reverse key-value pairs with number values and convert them to strings', () => { + const input = { age: 25, score: 100, year: 2023 }; + const expected = { '25': 'age', '100': 'score', '2023': 'year' }; + const result = reverseKeyValue(input); + expect(result).toEqual(expected); + }); + + test('should handle empty object', () => { + const input = {}; + const expected = {}; + const result = reverseKeyValue(input); + expect(result).toEqual(expected); + }); +}); + +describe('renameFieldsInQuery', () => { + test('should comprehensively rename fields in GraphQL query covering all scenarios', () => { + // Complex query with multiple fields, nested structure, arguments, and aliases + const query = ` + query GetUserData($id: ID!) { + user(id: $id) { + userName + userEmail + profile { + firstName + lastName + address { + street + city + } + } + posts(limit: 10) { + postTitle + postContent + author { + userName + } + } + unchangedField + } + settings { + theme + notifications + } + } + `; + + // Rename map covering various scenarios + const renameMap = { + userName: 'name', // Basic field rename + userEmail: 'email', // Another basic field rename + firstName: 'first_name', // Nested field rename + lastName: 'last_name', // Another nested field rename + postTitle: 'title', // Field in array/list + postContent: 'content', // Another field in array/list + // Note: unchangedField, street, city, theme, notifications are not in map - should remain unchanged + }; + + const result = renameFieldsInQuery(query, renameMap); + + // Verify all specified fields are renamed + expect(result).toContain('name'); // userName -> name + expect(result).toContain('email'); // userEmail -> email + expect(result).toContain('first_name'); // firstName -> first_name + expect(result).toContain('last_name'); // lastName -> last_name + expect(result).toContain('title'); // postTitle -> title + expect(result).toContain('content'); // postContent -> content + + // Verify fields not in rename map remain unchanged + expect(result).toContain('unchangedField'); + expect(result).toContain('street'); + expect(result).toContain('city'); + expect(result).toContain('theme'); + expect(result).toContain('notifications'); + + // Verify old field names are no longer present + expect(result).not.toContain('userName'); + expect(result).not.toContain('userEmail'); + expect(result).not.toContain('firstName'); + expect(result).not.toContain('lastName'); + expect(result).not.toContain('postTitle'); + expect(result).not.toContain('postContent'); + + // Verify the query structure remains valid (contains key GraphQL elements) + expect(result).toContain('query GetUserData'); + expect(result).toContain('$id: ID!'); + expect(result).toContain('user(id: $id)'); + expect(result).toContain('posts(limit: 10)'); + + // Test edge case: empty rename map should return original query + const resultWithEmptyMap = renameFieldsInQuery(query, {}); + expect(resultWithEmptyMap).toContain('userName'); + expect(resultWithEmptyMap).toContain('userEmail'); + expect(resultWithEmptyMap).toContain('firstName'); + }); +}); diff --git a/test/schema.graphql b/test/schema.graphql index 3a99cae..abd4ec9 100644 --- a/test/schema.graphql +++ b/test/schema.graphql @@ -29,6 +29,24 @@ ) } + type UserResponseKeyFormat { + name_test: String, + email: String! + id: String + account_type: String, + currrent_org: Org, + privileges: [String], + tenant_id: String, + user_groups: [UserGroup], + display_name: String, + key: String + history(from: String): [String] + @service( + path: "/v2/users/$identifier/history?from=$from", + method: "GET" + ) + } + type TokenResponse { token: String!, valid_for_userid: String!, @@ -82,6 +100,11 @@ "asd": "<%= args.asd %>" } """) + getGQLSessionDetailsWithResponseKeyFormat (sessionId: String, asd: String): UserResponseKeyFormat! @gqlrise(responseKeyFormat: """ + { + "name": "name_test" + } + """) } input ParamInput {