Skip to content

Commit

Permalink
fix(REST): do not perform any runtime verification (#1333)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-fenster authored Sep 1, 2022
1 parent b574655 commit f655f42
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 409 deletions.
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

0 comments on commit f655f42

Please sign in to comment.