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

fix(REST): do not perform any runtime verification #1333

Merged
merged 1 commit into from
Sep 1, 2022
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/fallbackRest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,7 @@ export function encodeRequest(
throw new Error(`Request to RPC ${rpc.name} must be an object.`);
}

const transcoded = transcode(
json,
rpc.parsedOptions,
rpc.resolvedRequestType!.fields
);
const transcoded = transcode(json, rpc.parsedOptions);

if (!transcoded) {
throw new Error(
Expand Down
236 changes: 58 additions & 178 deletions src/transcoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
// https://cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api#grpc-transcoding

import {JSONObject, JSONValue} from 'proto3-json-serializer';
import {Field, Type} from 'protobufjs';
import {Field} from 'protobufjs';
import {google} from '../protos/http';
import {GoogleError} from './googleError';
import {camelToSnakeCase, toCamelCase as snakeToCamelCase} from './util';
import {toCamelCase as snakeToCamelCase} from './util';

export interface TranscodedRequest {
httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete';
Expand All @@ -31,7 +30,6 @@ export interface TranscodedRequest {
}

const httpOptionName = '(google.api.http)';
const fieldBehaviorOptionName = '(google.api.field_behavior)';
const proto3OptionalName = 'proto3_optional';

// The following type is here only to make tests type safe
Expand All @@ -51,7 +49,8 @@ export type ParsedOptionsType = Array<

export function getField(
request: JSONObject,
field: string
field: string,
allowObjects = false // in most cases, we need leaf fields
): JSONValue | undefined {
const parts = field.split('.');
let value: JSONValue = request;
Expand All @@ -61,22 +60,43 @@ export function getField(
}
value = (value as JSONObject)[part] as JSONValue;
}
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
if (
!allowObjects &&
typeof value === 'object' &&
!Array.isArray(value) &&
value !== null
) {
return undefined;
}
return value;
}

export function deepCopy(request: JSONObject): JSONObject {
export function deepCopyWithoutMatchedFields(
request: JSONObject,
fieldsToSkip: Set<string>,
fullNamePrefix = ''
): JSONObject {
if (typeof request !== 'object' || request === null) {
return request;
}
const copy = Object.assign({}, request);
for (const key in copy) {
if (fieldsToSkip.has(`${fullNamePrefix}${key}`)) {
delete copy[key];
continue;
}
const nextFullNamePrefix = `${fullNamePrefix}${key}.`;
if (Array.isArray(copy[key])) {
copy[key] = (copy[key] as JSONObject[]).map(deepCopy);
// a field of an array cannot be addressed as "request.field", so we omit the skipping logic for array descendants
copy[key] = (copy[key] as JSONObject[]).map(value =>
deepCopyWithoutMatchedFields(value, new Set())
);
} else if (typeof copy[key] === 'object' && copy[key] !== null) {
copy[key] = deepCopy(copy[key] as JSONObject);
copy[key] = deepCopyWithoutMatchedFields(
copy[key] as JSONObject,
fieldsToSkip,
nextFullNamePrefix
);
}
}
return copy;
Expand Down Expand Up @@ -173,6 +193,11 @@ export function applyPattern(
return encodeWithoutSlashes(fieldValue);
}

function fieldToCamelCase(field: string): string {
const parts = field.split('.');
return parts.map(part => snakeToCamelCase(part)).join('.');
}

interface MatchResult {
matchedFields: string[];
url: string;
Expand All @@ -190,8 +215,9 @@ export function match(
break;
}
const [, before, field, pattern, after] = match;
matchedFields.push(field);
const fieldValue = getField(request, field);
const camelCasedField = fieldToCamelCase(field);
matchedFields.push(fieldToCamelCase(camelCasedField));
const fieldValue = getField(request, camelCasedField);
if (fieldValue === undefined) {
return undefined;
}
Expand Down Expand Up @@ -236,127 +262,14 @@ export function flattenObject(request: JSONObject): JSONObject {
return result;
}

export function requestChangeCaseAndCleanup(
request: JSONObject,
caseChangeFunc: (key: string) => string,
fieldsToChange?: Set<string>
) {
if (!request || typeof request !== 'object') {
return request;
}
const convertedRequest: JSONObject = {};
for (const field in request) {
// cleaning up inherited properties
if (!Object.prototype.hasOwnProperty.call(request, field)) {
continue;
}
let convertedField = caseChangeFunc(field);

// Here, we want to check if the fields in the proto match
// the fields we are changing; if not, we assume it's user
// input and revert back to its original form
if (
fieldsToChange &&
fieldsToChange?.size !== 0 &&
!fieldsToChange?.has(convertedField)
) {
convertedField = field;
}

const value = request[field];
if (Array.isArray(value)) {
convertedRequest[convertedField] = value.map(v =>
requestChangeCaseAndCleanup(
v as JSONObject,
caseChangeFunc,
fieldsToChange
)
);
} else {
convertedRequest[convertedField] = requestChangeCaseAndCleanup(
value as JSONObject,
caseChangeFunc,
fieldsToChange
);
}
}
return convertedRequest;
}

export function isProto3OptionalField(field: Field) {
return field && field.options && field.options![proto3OptionalName];
}

export function isRequiredField(field: Field) {
return (
field &&
field.options &&
field.options![fieldBehaviorOptionName] === 'REQUIRED'
);
}

export function getFieldNameOnBehavior(
fields: {[k: string]: Field} | undefined
) {
const requiredFields = new Set<string>();
const optionalFields = new Set<string>();
for (const fieldName in fields) {
const field = fields[fieldName];
if (isRequiredField(field)) {
requiredFields.add(fieldName);
}
if (isProto3OptionalField(field)) {
optionalFields.add(fieldName);
}
}
return {requiredFields, optionalFields};
}

// This function gets all the fields recursively
function getAllFieldNames(
fields: {[k: string]: Field} | undefined,
fieldNames: string[]
) {
if (fields) {
for (const field in fields) {
fieldNames.push(field);
if ((fields?.[field]?.resolvedType as Type)?.fields) {
getAllFieldNames(
(fields[field].resolvedType as Type).fields,
fieldNames
);
}
}
}
return fieldNames;
}

export function transcode(
request: JSONObject,
parsedOptions: ParsedOptionsType,
requestFields?: {[k: string]: Field}
parsedOptions: ParsedOptionsType
): TranscodedRequest | undefined {
const {requiredFields, optionalFields} =
getFieldNameOnBehavior(requestFields);
// all fields annotated as REQUIRED MUST be emitted in the body.
for (const requiredField of requiredFields) {
if (!(requiredField in request) || request[requiredField] === undefined) {
throw new GoogleError(
`Required field ${requiredField} is not present in the request.`
);
}
}
// request is supposed to have keys in camelCase.
let fieldsToChange = undefined;
if (requestFields) {
fieldsToChange = getAllFieldNames(requestFields, []);
fieldsToChange = fieldsToChange?.map(x => camelToSnakeCase(x));
}
const snakeRequest = requestChangeCaseAndCleanup(
request,
camelToSnakeCase,
new Set(fieldsToChange)
);
const httpRules = [];
for (const option of parsedOptions) {
if (!(httpOptionName in option)) {
Expand All @@ -382,73 +295,40 @@ export function transcode(
const pathTemplate = httpRule[
httpMethod as keyof google.api.IHttpRule
] as string;
const matchResult = match(snakeRequest, pathTemplate);
const matchResult = match(request, pathTemplate);
if (matchResult === undefined) {
continue;
}
const {url, matchedFields} = matchResult;

let data: JSONObject | JSONValue | undefined =
deepCopyWithoutMatchedFields(request, new Set(matchedFields));
if (httpRule.body === '*') {
// all fields except the matched fields go to request data
const data = deepCopy(snakeRequest);
for (const field of matchedFields) {
deleteField(data, field);
}
// Remove unset proto3 optional field from the request body.
for (const key in data) {
if (
optionalFields.has(snakeToCamelCase(key)) &&
(!(key in snakeRequest) || snakeRequest[key] === undefined)
) {
delete data[key];
}
}
// HTTP endpoint expects camelCase but we have snake_case at this point
fieldsToChange = fieldsToChange?.map(x => snakeToCamelCase(x));
const camelCaseData = requestChangeCaseAndCleanup(
data,
snakeToCamelCase,
new Set(fieldsToChange)
);
return {httpMethod, url, queryString: '', data: camelCaseData};
return {httpMethod, url, queryString: '', data};
}

// one field possibly goes to request data, others go to query string
const body = httpRule.body;
let data: string | JSONObject = '';
const queryStringObject = deepCopy(request); // use camel case for query string
if (body) {
deleteField(queryStringObject, snakeToCamelCase(body));
// Unset optional field should not add in body request.
data =
optionalFields.has(body) && snakeRequest[body] === undefined
? ''
: (snakeRequest[body] as JSONObject);
}
for (const field of matchedFields) {
deleteField(queryStringObject, snakeToCamelCase(field));
}
// Unset proto3 optional field does not appear in the query params.
for (const key in queryStringObject) {
if (optionalFields.has(key) && request[key] === undefined) {
delete queryStringObject[key];
}
const queryStringObject = data;
if (httpRule.body) {
data = getField(
queryStringObject,
fieldToCamelCase(httpRule.body),
/*allowObjects:*/ true
);
deleteField(queryStringObject, fieldToCamelCase(httpRule.body));
} else {
data = '';
}
const queryStringComponents =
buildQueryStringComponents(queryStringObject);
const queryString = queryStringComponents.join('&');
let camelCaseData: string | JSONObject;
if (typeof data === 'string') {
camelCaseData = data;
} else {
fieldsToChange = fieldsToChange?.map(x => snakeToCamelCase(x));
camelCaseData = requestChangeCaseAndCleanup(
data,
snakeToCamelCase,
new Set(fieldsToChange)
);
if (
!data ||
(typeof data === 'object' && Object.keys(data).length === 0)
) {
data = '';
}
return {httpMethod, url, queryString, data: camelCaseData};
return {httpMethod, url, queryString, data};
}
}
return undefined;
Expand Down
33 changes: 0 additions & 33 deletions test/fixtures/fallback-default-value/test_optional.proto

This file was deleted.

18 changes: 0 additions & 18 deletions test/fixtures/fallbackOptional.ts

This file was deleted.

Loading