go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/data/prpc.tsx (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  
    16  // gRPC status codes (see google/rpc/code.proto). Kept as UPPER_CASE to simplify
    17  // converting them to a canonical string representation via StatusCode[code].
    18  export enum StatusCode {
    19    OK = 0,
    20    CANCELLED = 1,
    21    UNKNOWN = 2,
    22    INVALID_ARGUMENT = 3,
    23    DEADLINE_EXCEEDED = 4,
    24    NOT_FOUND = 5,
    25    ALREADY_EXISTS = 6,
    26    PERMISSION_DENIED = 7,
    27    RESOURCE_EXHAUSTED = 8,
    28    FAILED_PRECONDITION = 9,
    29    ABORTED = 10,
    30    OUT_OF_RANGE = 11,
    31    UNIMPLEMENTED = 12,
    32    INTERNAL = 13,
    33    UNAVAILABLE = 14,
    34    DATA_LOSS = 15,
    35    UNAUTHENTICATED = 16,
    36  }
    37  
    38  
    39  // An RPC error with a status code and an error message.
    40  export class RPCError extends Error {
    41    readonly code: StatusCode;
    42    readonly http: number;
    43    readonly text: string;
    44  
    45    constructor(code: StatusCode, http: number, text: string) {
    46      super(`${StatusCode[code]} (HTTP ${http}): ${text}`);
    47      Object.setPrototypeOf(this, RPCError.prototype);
    48      this.code = code;
    49      this.http = http;
    50      this.text = text;
    51    }
    52  }
    53  
    54  
    55  // Descriptors holds all type information about RPC APIs exposed by a server.
    56  export class Descriptors {
    57    // A list of RPC services exposed by a server.
    58    readonly services: Service[] = [];
    59    // Helpers for visiting descriptors.
    60    private readonly types: Types;
    61    // A cache of visited message types.
    62    private readonly messages: Cache<Message>;
    63    // A cache of visited enum types.
    64    private readonly enums: Cache<Enum>;
    65  
    66    constructor(desc: FileDescriptorSet, services: string[]) {
    67      for (const fileDesc of (desc.file ?? [])) {
    68        resolveSourceLocation(fileDesc); // mutates `fileDesc` in-place
    69      }
    70      this.types = new Types(desc);
    71      for (const serviceName of services) {
    72        const desc = this.types.service(serviceName);
    73        if (desc) {
    74          this.services.push(new Service(serviceName, desc));
    75        }
    76      }
    77      this.messages = new Cache();
    78      this.enums = new Cache();
    79    }
    80  
    81    // Returns a service by its name or undefined if no such service.
    82    service(serviceName: string): Service | undefined {
    83      return this.services.find((service) => service.name == serviceName);
    84    }
    85  
    86    // Returns a message descriptor given its name or undefined if not found.
    87    message(messageName: string): Message | undefined {
    88      return this.messages.get(messageName, () => {
    89        const desc = this.types.message(messageName);
    90        return desc ? new Message(desc, this) : undefined;
    91      });
    92    }
    93  
    94    // Returns an enum descriptor given its name or undefined if not found.
    95    enum(enumName: string): Enum | undefined {
    96      return this.enums.get(enumName, () => {
    97        const desc = this.types.enum(enumName);
    98        return desc ? new Enum(desc) : undefined;
    99      });
   100    }
   101  }
   102  
   103  
   104  // Describes a structure of a message.
   105  export class Message {
   106    // True if this message represents some map<K,V> entry.
   107    readonly mapEntry: boolean;
   108    // The list of fields of this message.
   109    readonly fields: Field[];
   110  
   111    constructor(desc: DescriptorProto, root: Descriptors) {
   112      this.mapEntry = desc.options?.mapEntry ?? false;
   113      this.fields = [];
   114      for (const field of (desc.field ?? [])) {
   115        this.fields.push(new Field(field, root));
   116      }
   117    }
   118  
   119    // Returns a field given its JSON name.
   120    fieldByJsonName(name: string): Field | undefined {
   121      for (const field of this.fields) {
   122        if (field.jsonName == name) {
   123          return field;
   124        }
   125      }
   126      return undefined;
   127    }
   128  }
   129  
   130  
   131  // Describes possible values of an enum.
   132  export class Enum {
   133    // Possible values of an enum.
   134    readonly values: EnumValue[];
   135  
   136    constructor(desc: EnumDescriptorProto) {
   137      this.values = [];
   138      for (const val of (desc.value ?? [])) {
   139        this.values.push(new EnumValue(val));
   140      }
   141    }
   142  }
   143  
   144  
   145  // A single possible value of an enum
   146  export class EnumValue {
   147    // E.g. "SOME_VALUE";
   148    readonly name: string;
   149  
   150    private readonly desc: EnumValueDescriptorProto;
   151    private cachedDoc: string | null;
   152  
   153    constructor(desc: EnumValueDescriptorProto) {
   154      this.name = desc.name;
   155      this.desc = desc;
   156      this.cachedDoc = null;
   157    }
   158  
   159    // Paragraphs with documentation extracted from comments in the proto file.
   160    get doc(): string {
   161      if (this.cachedDoc == null) {
   162        this.cachedDoc = extractDoc(this.desc.resolvedSourceLocation);
   163      }
   164      return this.cachedDoc;
   165    }
   166  }
   167  
   168  
   169  // How field value is encoded in JSON.
   170  export enum JSONType {
   171    Object,
   172    List,
   173    String,
   174    Scalar, // numbers, booleans, null
   175  }
   176  
   177  
   178  // Describes a structure of a single field.
   179  export class Field {
   180    // Fields JSON name, e.g. `someField`.
   181    readonly jsonName: string;
   182    // True if this is a repeated field or a map field.
   183    readonly repeated: boolean;
   184  
   185    private readonly desc: FieldDescriptorProto;
   186    private readonly root: Descriptors;
   187    private cachedDoc: string | null;
   188  
   189    static readonly scalarTypeNames = new Map([
   190      ['TYPE_DOUBLE', 'double'],
   191      ['TYPE_FLOAT', 'float'],
   192      ['TYPE_INT64', 'int64'],
   193      ['TYPE_UINT64', 'uint64'],
   194      ['TYPE_INT32', 'int32'],
   195      ['TYPE_FIXED64', 'fixed64'],
   196      ['TYPE_FIXED32', 'fixed32'],
   197      ['TYPE_BOOL', 'bool'],
   198      ['TYPE_STRING', 'string'],
   199      ['TYPE_BYTES', 'bytes'],
   200      ['TYPE_UINT32', 'uint32'],
   201      ['TYPE_SFIXED32', 'sfixed32'],
   202      ['TYPE_SFIXED64', 'sfixed64'],
   203      ['TYPE_SINT32', 'sint32'],
   204      ['TYPE_SINT64', 'sint64'],
   205    ]);
   206  
   207    constructor(desc: FieldDescriptorProto, root: Descriptors) {
   208      this.desc = desc;
   209      this.root = root;
   210      this.jsonName = desc.jsonName ?? '';
   211      this.repeated = desc.label == 'LABEL_REPEATED';
   212      this.cachedDoc = null;
   213    }
   214  
   215    // For message-valued fields, the type of the field value.
   216    //
   217    // Works for both singular and repeated fields.
   218    get message(): Message | undefined {
   219      if (this.desc.type == 'TYPE_MESSAGE') {
   220        return this.root.message(this.desc.typeName ?? '');
   221      }
   222      return undefined;
   223    }
   224  
   225    // For enum-valued fields, the type of the field value.
   226    //
   227    // Works for both singular and repeated fields.
   228    get enum(): Enum | undefined {
   229      if (this.desc.type == 'TYPE_ENUM') {
   230        return this.root.enum(this.desc.typeName ?? '');
   231      }
   232      return undefined;
   233    }
   234  
   235    // Field type name, approximately as it appears in the proto file.
   236    //
   237    // Recognizes repeated fields and maps.
   238    get type(): string {
   239      const pfx = this.repeated ? 'repeated ' : '';
   240      const scalar = Field.scalarTypeNames.get(this.desc.type);
   241      if (scalar) {
   242        return pfx + scalar;
   243      }
   244      const message = this.message;
   245      if (message && message.mapEntry) {
   246        // Note: mapEntry fields are always implicitly repeated, omit `pfx`.
   247        return `map<${message.fields[0].type}, ${message.fields[1].type}>`;
   248      }
   249      return pfx + trimDot(this.desc.typeName ?? 'unknown');
   250    }
   251  
   252    // Type of the field value in JSON representation.
   253    //
   254    // Recognizes repeated fields and maps.
   255    get jsonType(): JSONType {
   256      if (this.repeated) {
   257        if (this.message?.mapEntry) {
   258          return JSONType.Object;
   259        }
   260        return JSONType.List;
   261      }
   262      return this.jsonElementType;
   263    }
   264  
   265    // JSON type of the base element.
   266    //
   267    // For repeated fields it is the type inside the list.
   268    get jsonElementType(): JSONType {
   269      switch (this.desc.type) {
   270        case 'TYPE_MESSAGE':
   271          return JSONType.Object;
   272        case 'TYPE_ENUM':
   273        case 'TYPE_STRING':
   274        case 'TYPE_BYTES':
   275          return JSONType.String;
   276        default:
   277          return JSONType.Scalar;
   278      }
   279    }
   280  
   281    // Paragraphs with documentation extracted from comments in the proto file.
   282    get doc(): string {
   283      if (this.cachedDoc == null) {
   284        this.cachedDoc = extractDoc(this.desc.resolvedSourceLocation);
   285      }
   286      return this.cachedDoc;
   287    }
   288  }
   289  
   290  
   291  // Descriptions of a single RPC service.
   292  export class Service {
   293    // Name of the service, e.g. `discovery.Discovery`.
   294    readonly name: string;
   295    // The last component of the name, e.g. `Discovery`.
   296    readonly title: string;
   297    // Short description e.g. `Describes services.`.
   298    readonly help: string;
   299    // Paragraphs with full documentation.
   300    readonly doc: string;
   301    // List of methods exposed by the service.
   302    readonly methods: Method[] = [];
   303  
   304    constructor(name: string, desc: ServiceDescriptorProto) {
   305      this.name = name;
   306      this.title = splitFullName(name)[1];
   307      this.help = extractHelp(desc.resolvedSourceLocation, this.title, 'service');
   308      this.doc = extractDoc(desc.resolvedSourceLocation);
   309      if (desc.method !== undefined) {
   310        for (const methodDesc of desc.method) {
   311          this.methods.push(new Method(this.name, methodDesc));
   312        }
   313      }
   314    }
   315  
   316    // Returns a method by its name or undefined if no such method.
   317    method(methodName: string): Method | undefined {
   318      return this.methods.find((method) => method.name == methodName);
   319    }
   320  }
   321  
   322  
   323  // Method describes a single RPC method.
   324  export class Method {
   325    // Service name the method belongs to.
   326    readonly service: string;
   327    // Name of the method, e.g. `Describe `.
   328    readonly name: string;
   329    // Short description e.g. `Returns ...`.
   330    readonly help: string;
   331    // Paragraphs with full documentation.
   332    readonly doc: string;
   333    // Name of the protobuf message type of the request.
   334    readonly requestType: string;
   335  
   336    constructor(service: string, desc: MethodDescriptorProto) {
   337      this.service = service;
   338      this.name = desc.name;
   339      this.help = extractHelp(desc.resolvedSourceLocation, desc.name, 'method');
   340      this.doc = extractDoc(desc.resolvedSourceLocation);
   341      this.requestType = desc.inputType;
   342    }
   343  
   344    // Invokes this method, returns prettified JSON response as a string.
   345    //
   346    // `authorization` will be used as a value of Authorization header. `traceID`
   347    // is used to populate X-Cloud-Trace-Context header.
   348    async invoke(
   349        request: string,
   350        authorization: string,
   351        traceID?: string,
   352    ): Promise<string> {
   353      const resp: object = await invokeMethod(
   354          this.service, this.name, request, authorization, traceID,
   355      );
   356      return JSON.stringify(resp, null, 2);
   357    }
   358  }
   359  
   360  
   361  // Loads RPC API descriptors from the server.
   362  export const loadDescriptors = async (): Promise<Descriptors> => {
   363    const resp: DescribeResponse = await invokeMethod(
   364        'discovery.Discovery', 'Describe', '{}', '',
   365    );
   366    return new Descriptors(resp.description, resp.services);
   367  };
   368  
   369  
   370  // Generates a tracing ID for Cloud Trace.
   371  //
   372  // See https://cloud.google.com/trace/docs/setup#force-trace.
   373  export const generateTraceID = (): string => {
   374    let output = '';
   375    for (let i = 0; i < 32; ++i) {
   376      output += (Math.floor(Math.random() * 16)).toString(16);
   377    }
   378    return output;
   379  };
   380  
   381  
   382  // Private guts.
   383  
   384  
   385  // A helper to cache visited types.
   386  class Cache<V> {
   387    private cache: Map<string, V | 'none'>;
   388  
   389    constructor() {
   390      this.cache = new Map();
   391    }
   392  
   393    get(key: string, val: () => V | undefined): V | undefined {
   394      let cached = this.cache.get(key);
   395      switch (cached) {
   396        case 'none':
   397        // Cached "absence".
   398          return undefined;
   399        case undefined:
   400        // Not in the cache yet.
   401          cached = val();
   402          this.cache.set(key, cached == undefined ? 'none' : cached);
   403          return cached;
   404        default:
   405          return cached;
   406      }
   407    }
   408  }
   409  
   410  
   411  // Types knows how to look up proto descriptors given full proto type names.
   412  class Types {
   413    // Proto package name => list of FileDescriptorProto where it is defined.
   414    private packageMap = new Map<string, FileDescriptorProto[]>();
   415  
   416    constructor(desc: FileDescriptorSet) {
   417      for (const fileDesc of (desc.file ?? [])) {
   418        const descs = this.packageMap.get(fileDesc.package);
   419        if (descs === undefined) {
   420          this.packageMap.set(fileDesc.package, [fileDesc]);
   421        } else {
   422          descs.push(fileDesc);
   423        }
   424      }
   425    }
   426  
   427    // Given a full service name returns its descriptor or undefined.
   428    service(fullName: string): ServiceDescriptorProto | undefined {
   429      const [pkg, name] = splitFullName(fullName);
   430      return this.visitPackage(pkg, (fileDesc) => {
   431        for (const svc of (fileDesc.service ?? [])) {
   432          if (svc.name == name) {
   433            return svc;
   434          }
   435        }
   436        return undefined;
   437      });
   438    }
   439  
   440    // Given a full message name returns its descriptor or undefined.
   441    message(fullName: string): DescriptorProto | undefined {
   442      // Assume this is a top-level type defined in some package.
   443      const [pkg, name] = splitFullName(fullName);
   444      const found = this.visitPackage(pkg, (fileDesc) => {
   445        for (const desc of (fileDesc.messageType ?? [])) {
   446          if (desc.name == name) {
   447            return desc;
   448          }
   449        }
   450        return undefined;
   451      });
   452      if (found) {
   453        return found;
   454      }
   455      // It might be a reference to a nested type, in which case `pkg` is actually
   456      // some message name itself. Try to find it.
   457      const parent = this.message(pkg);
   458      if (parent) {
   459        for (const desc of (parent.nestedType ?? [])) {
   460          if (desc.name == name) {
   461            return desc;
   462          }
   463        }
   464      }
   465      return undefined;
   466    }
   467  
   468    // Given a full enum name returns its descriptor or undefined.
   469    enum(fullName: string): EnumDescriptorProto | undefined {
   470      // Assume this is a top-level type defined in some package.
   471      const [pkg, name] = splitFullName(fullName);
   472      const found = this.visitPackage(pkg, (fileDesc) => {
   473        for (const msg of (fileDesc.enumType ?? [])) {
   474          if (msg.name == name) {
   475            return msg;
   476          }
   477        }
   478        return undefined;
   479      });
   480      if (found) {
   481        return found;
   482      }
   483      // It might be a reference to a nested type, in which case `pkg` is actually
   484      // some message name. Try to find it.
   485      const parent = this.message(pkg);
   486      if (parent) {
   487        for (const msg of (parent.enumType ?? [])) {
   488          if (msg.name == name) {
   489            return msg;
   490          }
   491        }
   492      }
   493      return undefined;
   494    }
   495  
   496    // Visits all FileDescriptorProto that define a particular package.
   497    private visitPackage<T>(
   498        pkg: string,
   499        cb: (desc: FileDescriptorProto) => T | undefined,
   500    ): T | undefined {
   501      const descs = this.packageMap.get(pkg);
   502      if (descs !== undefined) {
   503        for (const desc of descs) {
   504          const res = cb(desc);
   505          if (res !== undefined) {
   506            return res;
   507          }
   508        }
   509      }
   510      return undefined;
   511    }
   512  }
   513  
   514  
   515  // resolveSourceLocation recursively populates `resolvedSourceLocation`.
   516  //
   517  // See https://github.com/protocolbuffers/protobuf/blob/5a5683/src/google/protobuf/descriptor.proto#L803
   518  const resolveSourceLocation = (desc: FileDescriptorProto) => {
   519    if (!desc.sourceCodeInfo) {
   520      return;
   521    }
   522  
   523    // A helper to convert a location path to a string key.
   524    const locationKey = (path: number[]): string => {
   525      return path.map((n) => n.toString()).join('.');
   526    };
   527  
   528    // Build a map {path -> SourceCodeInfoLocation}. See the link above for
   529    // what a path is.
   530    const locationMap = new Map<string, SourceCodeInfoLocation>();
   531    for (const location of (desc.sourceCodeInfo.location ?? [])) {
   532      if (location.path) {
   533        locationMap.set(locationKey(location.path), location);
   534      }
   535    }
   536  
   537    // Now recursively traverse the tree of definitions in the `desc` and
   538    // for each visited path see if there's a location for it in `locationMap`.
   539    //
   540    // Various magical numbers below are message field numbers defined in
   541    // descriptor.proto (again, see the link above, it is a pretty confusing
   542    // mechanism and comments in descriptor.proto are the best explanation).
   543  
   544    // Helper for recursively diving into a list of definitions of some kind.
   545    const resolveList = <T extends HasResolved>(
   546      path: number[],
   547      field: number,
   548      list: T[] | undefined,
   549      dive?: (path: number[], val: T) => void,
   550    ) => {
   551      if (!list) {
   552        return;
   553      }
   554      path.push(field);
   555      for (const [idx, val] of list.entries()) {
   556        path.push(idx);
   557        val.resolvedSourceLocation = locationMap.get(locationKey(path));
   558        if (dive) {
   559          dive(path, val);
   560        }
   561        path.pop();
   562      }
   563      path.pop();
   564    };
   565  
   566    const resolveService = (path: number[], msg: ServiceDescriptorProto) => {
   567      resolveList(path, 2, msg.method);
   568    };
   569  
   570    const resolveEnum = (path: number[], msg: EnumDescriptorProto) => {
   571      resolveList(path, 2, msg.value);
   572    };
   573  
   574    const resolveMessage = (path: number[], msg: DescriptorProto) => {
   575      resolveList(path, 2, msg.field);
   576      resolveList(path, 3, msg.nestedType, resolveMessage);
   577      resolveList(path, 4, msg.enumType, resolveEnum);
   578    };
   579  
   580    // Root lists.
   581    resolveList([], 4, desc.messageType, resolveMessage);
   582    resolveList([], 5, desc.enumType, resolveEnum);
   583    resolveList([], 6, desc.service, resolveService);
   584  };
   585  
   586  
   587  // extractHelp extracts a first paragraph of the leading comment and trims it
   588  // a bit if necessary.
   589  //
   590  // A paragraph is delimited by '\n\n' or '.  '.
   591  const extractHelp = (
   592      loc: SourceCodeInfoLocation | undefined,
   593      subject: string,
   594      type: string,
   595  ): string => {
   596    let comment = (loc ? loc.leadingComments ?? '' : '').trim();
   597    if (!comment) {
   598      return '';
   599    }
   600  
   601    // Get the first paragraph.
   602    comment = splitParagraph(comment)[0];
   603  
   604    // Go-based services very often have leading comments that start with
   605    // "ServiceName service does blah". We convert this to just "Does blah",
   606    // since we already show the service name prominently and this duplication
   607    // looks bad.
   608    const [trimmed, yes] = trimWord(comment, subject);
   609    if (yes) {
   610      const evenMore = trimWord(trimmed, type)[0];
   611      if (evenMore) {
   612        comment = evenMore.charAt(0).toUpperCase() + evenMore.substring(1);
   613      }
   614    }
   615  
   616    return deindent(comment);
   617  };
   618  
   619  
   620  // extractDoc converts comments into a list of paragraphs separated by '\n\n'.
   621  const extractDoc = (loc: SourceCodeInfoLocation | undefined): string => {
   622    let text = (loc ? loc.leadingComments ?? '' : '').trim();
   623    const paragraphs: string[] = [];
   624    while (text) {
   625      const [p, remains] = splitParagraph(text);
   626      if (p) {
   627        paragraphs.push(deindent(p));
   628      }
   629      text = remains;
   630    }
   631    return paragraphs.join('\n\n');
   632  };
   633  
   634  
   635  // splitParagraph returns the first paragraph and what's left.
   636  const splitParagraph = (s: string): [string, string] => {
   637    let idx = s.indexOf('\n\n');
   638    if (idx == -1) {
   639      idx = s.indexOf('.  ');
   640      if (idx != -1) {
   641        idx += 1; // include the dot
   642      }
   643    }
   644    if (idx != -1) {
   645      return [s.substring(0, idx).trim(), s.substring(idx).trim()];
   646    }
   647    return [s, ''];
   648  };
   649  
   650  
   651  // deindent removes a leading space from all lines that have it.
   652  //
   653  // Protobuf docs are generated from comments not unlike this one. `protoc`
   654  // strips `//` but does nothing to spaces that follow `//`. As a result docs are
   655  // intended by one space.
   656  const deindent = (s: string): string => {
   657    const lines = s.split('\n');
   658    for (let i = 0; i < lines.length; i++) {
   659      if (lines[i].startsWith(' ')) {
   660        lines[i] = lines[i].substring(1);
   661      }
   662    }
   663    return lines.join('\n');
   664  };
   665  
   666  
   667  // trimWord removes a leading word from a sentence, kind of.
   668  const trimWord = (s: string, word: string): [string, boolean] => {
   669    if (s.startsWith(word + ' ')) {
   670      return [s.substring(word.length + 1).trimLeft(), true];
   671    }
   672    return [s, false];
   673  };
   674  
   675  
   676  // ".google.protobuf.Empty" => "google.protobuf.Empty";
   677  const trimDot = (name: string): string => {
   678    if (name.startsWith('.')) {
   679      return name.substring(1);
   680    }
   681    return name;
   682  };
   683  
   684  
   685  // ".google.protobuf.Empty" => ["google.protobuf", "Empty"].
   686  const splitFullName = (fullName: string): [string, string] => {
   687    const lastDot = fullName.lastIndexOf('.');
   688    const start = fullName.startsWith('.') ? 1 : 0;
   689    return [
   690      fullName.substring(start, lastDot),
   691      fullName.substring(lastDot + 1),
   692    ];
   693  };
   694  
   695  
   696  // invokeMethod sends a pRPC request and parses the response.
   697  const invokeMethod = async <T, >(
   698    service: string,
   699    method: string,
   700    body: string,
   701    authorization: string,
   702    traceID?: string,
   703  ): Promise<T> => {
   704    const headers = new Headers();
   705    headers.set('Accept', 'application/json; charset=utf-8');
   706    headers.set('Content-Type', 'application/json; charset=utf-8');
   707    if (authorization) {
   708      headers.set('Authorization', authorization);
   709    }
   710    if (traceID) {
   711      headers.set('X-Cloud-Trace-Context', `${traceID}/1;o=1`);
   712    }
   713  
   714    const response = await fetch(`/prpc/${service}/${method}`, {
   715      method: 'POST',
   716      credentials: 'omit',
   717      headers: headers,
   718      body: body,
   719    });
   720    let responseBody = await response.text();
   721  
   722    // Trim the magic anti-XSS prefix if present.
   723    const pfx = ')]}\'';
   724    if (responseBody.startsWith(pfx)) {
   725      responseBody = responseBody.slice(pfx.length);
   726    }
   727  
   728    // Parse gRPC status code header if available.
   729    let grpcCode = StatusCode.UNKNOWN;
   730    const codeStr = response.headers.get('X-Prpc-Grpc-Code');
   731    if (codeStr) {
   732      const num = parseInt(codeStr);
   733      if (isNaN(num) || num < 0 || num > 16) {
   734        grpcCode = StatusCode.UNKNOWN;
   735      } else {
   736        grpcCode = num;
   737      }
   738    } else if (response.status >= 500) {
   739      grpcCode = StatusCode.INTERNAL;
   740    }
   741  
   742    if (grpcCode != StatusCode.OK) {
   743      throw new RPCError(grpcCode, response.status, responseBody.trim());
   744    }
   745  
   746    return JSON.parse(responseBody) as T;
   747  };
   748  
   749  
   750  // See go.chromium.org/luci/grpc/discovery/service.proto and protos it imports
   751  // (recursively).
   752  //
   753  // Only fields used by RPC Explorer are exposed below, using their JSONPB names.
   754  //
   755  // To simplify extraction of code comments for various definitions we also add
   756  // an extra field `resolvedSourceLocation` to some interfaces. Its value is
   757  // derived from `sourceCodeInfo` in resolveSourceLocation.
   758  
   759  // Implemented by descriptors that have resolve source location attached.
   760  interface HasResolved {
   761    resolvedSourceLocation?: SourceCodeInfoLocation;
   762  }
   763  
   764  interface DescribeResponse {
   765    description: FileDescriptorSet;
   766    services: string[];
   767  }
   768  
   769  export interface FileDescriptorSet {
   770    file?: FileDescriptorProto[];
   771  }
   772  
   773  interface FileDescriptorProto {
   774    name: string;
   775    package: string;
   776  
   777    messageType?: DescriptorProto[]; // = 4
   778    enumType?: EnumDescriptorProto[]; // = 5;
   779    service?: ServiceDescriptorProto[]; // = 6
   780  
   781    sourceCodeInfo?: SourceCodeInfo;
   782  }
   783  
   784  interface DescriptorProto extends HasResolved {
   785    name: string;
   786    field?: FieldDescriptorProto[]; // = 2
   787    nestedType?: DescriptorProto[]; // = 3
   788    enumType?: EnumDescriptorProto[]; // = 4
   789    options?: MessageOptions;
   790  }
   791  
   792  interface FieldDescriptorProto extends HasResolved {
   793    name: string;
   794    jsonName?: string;
   795    type: string; // e.g. "TYPE_INT32"
   796    typeName?: string; // for message and enum types only
   797    label: string; // e.g. "LABEL_REPEATED"
   798  }
   799  
   800  interface EnumDescriptorProto extends HasResolved {
   801    name: string;
   802    value?: EnumValueDescriptorProto[]; // = 2;
   803  }
   804  
   805  interface EnumValueDescriptorProto extends HasResolved {
   806    name: string;
   807    number: number;
   808  }
   809  
   810  interface ServiceDescriptorProto extends HasResolved {
   811    name: string;
   812    method?: MethodDescriptorProto[]; // = 2
   813  }
   814  
   815  interface MethodDescriptorProto extends HasResolved {
   816    name: string;
   817  
   818    inputType: string;
   819    outputType: string;
   820  }
   821  
   822  interface MessageOptions {
   823    mapEntry?: boolean;
   824  }
   825  
   826  interface SourceCodeInfo {
   827    location?: SourceCodeInfoLocation[];
   828  }
   829  
   830  interface SourceCodeInfoLocation {
   831    path: number[];
   832    leadingComments?: string;
   833  }