File

src/transcoding.ts

Index

Properties

Properties

data
data: string | literal type
Type : string | literal type
httpMethod
httpMethod: "get" | "post" | "put" | "patch" | "delete"
Type : "get" | "post" | "put" | "patch" | "delete"
queryString
queryString: string
Type : string
url
url: string
Type : string
import {JSONObject, JSONValue} from 'proto3-json-serializer';
import {Field} from 'protobufjs';
import {google} from '../protos/http';
import {camelToSnakeCase, snakeToCamelCase} from './util';

export interface TranscodedRequest {
  httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete';
  url: string;
  queryString: string;
  data: string | {};
}

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
type allowedOptions = '(google.api.method_signature)';

// List of methods as defined in google/api/http.proto (see HttpRule)
const supportedHttpMethods: Array<'get' | 'put' | 'post' | 'patch' | 'delete'> =
  ['get', 'post', 'put', 'patch', 'delete'];

export type ParsedOptionsType = Array<
  {
    [httpOptionName]?: google.api.IHttpRule;
  } & {
    [option in allowedOptions]?: {} | string | number;
  }
>;

export function getField(
  request: JSONObject,
  field: string
): JSONValue | undefined {
  const parts = field.split('.');
  let value: JSONValue = request;
  for (const part of parts) {
    if (typeof value !== 'object') {
      return undefined;
    }
    value = (value as JSONObject)[part] as JSONValue;
  }
  if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
    return undefined;
  }
  return value;
}

export function deepCopy(request: JSONObject): JSONObject {
  if (typeof request !== 'object' || request === null) {
    return request;
  }
  const copy = Object.assign({}, request);
  for (const key in copy) {
    if (Array.isArray(copy[key])) {
      copy[key] = (copy[key] as JSONObject[]).map(deepCopy);
    } else if (typeof copy[key] === 'object' && copy[key] !== null) {
      copy[key] = deepCopy(copy[key] as JSONObject);
    }
  }
  return copy;
}

export function deleteField(request: JSONObject, field: string): void {
  const parts = field.split('.');
  while (parts.length > 1) {
    if (typeof request !== 'object') {
      return;
    }
    const part = parts.shift() as string;
    request = request[part] as JSONObject;
  }
  const part = parts.shift() as string;
  if (typeof request !== 'object') {
    return;
  }
  delete request[part];
}

export function buildQueryStringComponents(
  request: JSONObject,
  prefix = ''
): string[] {
  const resultList = [];
  for (const key in request) {
    if (Array.isArray(request[key])) {
      for (const value of request[key] as JSONObject[]) {
        resultList.push(
          `${prefix}${encodeWithoutSlashes(key)}=${encodeWithoutSlashes(
            value.toString()
          )}`
        );
      }
    } else if (typeof request[key] === 'object' && request[key] !== null) {
      resultList.push(
        ...buildQueryStringComponents(request[key] as JSONObject, `${key}.`)
      );
    } else {
      resultList.push(
        `${prefix}${encodeWithoutSlashes(key)}=${encodeWithoutSlashes(
          request[key] === null ? 'null' : request[key]!.toString()
        )}`
      );
    }
  }
  return resultList;
}

export function encodeWithSlashes(str: string): string {
  return str
    .split('')
    .map(c => (c.match(/[-_.~0-9a-zA-Z]/) ? c : encodeURIComponent(c)))
    .join('');
}

export function encodeWithoutSlashes(str: string): string {
  return str
    .split('')
    .map(c => (c.match(/[-_.~0-9a-zA-Z/]/) ? c : encodeURIComponent(c)))
    .join('');
}

function escapeRegExp(str: string) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export function applyPattern(
  pattern: string,
  fieldValue: string
): string | undefined {
  if (!pattern || pattern === '*') {
    return encodeWithSlashes(fieldValue);
  }

  if (!pattern.includes('*') && pattern !== fieldValue) {
    return undefined;
  }

  // since we're converting the pattern to a regex, make necessary precautions:
  const regex = new RegExp(
    '^' +
      escapeRegExp(pattern)
        .replace(/\\\*\\\*/g, '(.+)')
        .replace(/\\\*/g, '([^/]+)') +
      '$'
  );

  if (!fieldValue.match(regex)) {
    return undefined;
  }

  return encodeWithoutSlashes(fieldValue);
}

interface MatchResult {
  matchedFields: string[];
  url: string;
}

export function match(
  request: JSONObject,
  pattern: string
): MatchResult | undefined {
  let url = pattern;
  const matchedFields = [];
  for (;;) {
    const match = url.match(/^(.*)\{([^}=]+)(?:=([^}]*))?\}(.*)/);
    if (!match) {
      break;
    }
    const [, before, field, pattern, after] = match;
    matchedFields.push(field);
    const fieldValue = getField(request, field);
    if (typeof fieldValue === 'undefined') {
      return undefined;
    }
    const appliedPattern = applyPattern(
      pattern,
      fieldValue === null ? 'null' : fieldValue!.toString()
    );
    if (typeof appliedPattern === 'undefined') {
      return undefined;
    }
    url = before + appliedPattern + after;
  }

  return {matchedFields, url};
}

export function flattenObject(request: JSONObject): JSONObject {
  const result: JSONObject = {};
  for (const key in request) {
    if (typeof request[key] === 'undefined') {
      continue;
    }

    if (Array.isArray(request[key])) {
      // According to the http.proto comments, a repeated field may only
      // contain primitive types, so no extra recursion here.
      result[key] = request[key];
      continue;
    }

    if (typeof request[key] === 'object' && request[key] !== null) {
      const nested = flattenObject(request[key] as JSONObject);
      for (const nestedKey in nested) {
        result[`${key}.${nestedKey}`] = nested[nestedKey];
      }
      continue;
    }

    result[key] = request[key];
  }

  return result;
}

export function requestChangeCaseAndCleanup(
  request: JSONObject,
  caseChangeFunc: (key: string) => 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;
    }
    const convertedField = caseChangeFunc(field);
    const value = request[field];
    if (Array.isArray(value)) {
      convertedRequest[convertedField] = value.map(v =>
        requestChangeCaseAndCleanup(v as JSONObject, caseChangeFunc)
      );
    } else {
      convertedRequest[convertedField] = requestChangeCaseAndCleanup(
        value as JSONObject,
        caseChangeFunc
      );
    }
  }
  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};
}

export function transcode(
  request: JSONObject,
  parsedOptions: ParsedOptionsType,
  requestFields?: {[k: string]: Field}
): 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 Error(
        `Required field ${requiredField} is not present in the request.`
      );
    }
  }
  // request is supposed to have keys in camelCase.
  const snakeRequest = requestChangeCaseAndCleanup(request, camelToSnakeCase);
  const httpRules = [];
  for (const option of parsedOptions) {
    if (!(httpOptionName in option)) {
      continue;
    }

    const httpRule = option[httpOptionName] as google.api.IHttpRule;
    httpRules.push(httpRule);

    if (httpRule?.additional_bindings) {
      const additionalBindings = Array.isArray(httpRule.additional_bindings)
        ? httpRule.additional_bindings
        : [httpRule.additional_bindings];
      httpRules.push(...additionalBindings);
    }
  }

  for (const httpRule of httpRules) {
    for (const httpMethod of supportedHttpMethods) {
      if (!(httpMethod in httpRule)) {
        continue;
      }
      const pathTemplate = httpRule[
        httpMethod as keyof google.api.IHttpRule
      ] as string;
      const matchResult = match(snakeRequest, pathTemplate);
      if (typeof matchResult === 'undefined') {
        continue;
      }
      const {url, matchedFields} = matchResult;

      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
        const camelCaseData = requestChangeCaseAndCleanup(
          data,
          snakeToCamelCase
        );
        return {httpMethod, url, queryString: '', data: camelCaseData};
      }

      // 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 queryStringComponents =
        buildQueryStringComponents(queryStringObject);
      const queryString = queryStringComponents.join('&');
      let camelCaseData: string | JSONObject;
      if (typeof data === 'string') {
        camelCaseData = data;
      } else {
        camelCaseData = requestChangeCaseAndCleanup(data, snakeToCamelCase);
      }
      return {httpMethod, url, queryString, data: camelCaseData};
    }
  }
  return undefined;
}

result-matching ""

    No results matching ""