square / wire

gRPC and protocol buffers for Android, Kotlin, Swift and Java.
https://square.github.io/wire/
Apache License 2.0
4.25k stars 570 forks source link

Add support for TypeScript code generation #2146

Closed jerrypopsoff closed 1 year ago

jerrypopsoff commented 2 years ago

Add support for developers to generate TypeScript based on Wire protocol buffer definitions. See Generating Code with Wire for details pertaining to existing code generation support.

Several developers/teams have expressed interest in generating TypeScript code corresponding to Wire protocol buffers, so that developers can be empowered to leverage type-safe interfaces, enums, etc. corresponding to Wire protocol buffers within framework-agnostic web and Node environments. While there are multiple existing solutions (e.g. WireTypeScriptGenerator), none are particularly well-maintained, have flexible build tooling/environment requirements, or are officially supported/recommended as a mechanism to produce consistent results. Additionally, existing solutions manually create files and append individual lines of code instead of leveraging a tool like typescriptpoet.

Implementing TypeScript code generation within the Wire repository would foster discoverability, consistency, and likely other benefits.

Example

CLI usage

A --typescript flag can be exposed to the compiler similarly to the --android flag to indicate TypeScript output is desired.

% java -jar wire-compiler-VERSION-jar-with-dependencies.jar \
    --typescript \
    --proto_path=src/main/proto \
    --out=protos \
    squareup/dinosaurs/dinosaur.proto \
    squareup/geology/period.proto
Writing com.squareup.dinosaurs.Dinosaur to protos
Writing com.squareup.geology.Period to protos

Output

Type definition example

// protos/squareup/dinosaurs/dinosaur.ts

import { Period } from 'protos/squareup/period/period';
import { Geolocation } from 'protos/squareup/common/geolocation';

export enum DietType {
  UNKNOWN = 'UNKNOWN',
  VEGETARIAN = 'VEGETARIAN',
  CARNIVORE = 'CARNIVORE',
  OMNIVORE = 'OMNIVORE',
}

export interface DefenseMechanism {
  sharp_teeth: boolean;
  swinging_tail: boolean;
}

export interface SleepSchedule {
  start: number;
  end: number;
}

export interface Dinosaur {
  name?: string;
  length_meters?: number;
  picture_urls: string[];
  mass_kilograms?: number;
  period?: Period;
  location?: Geolocation;
  diet_type?: DietType;
  defense_mechanism?: DefenseMechanism;
  sleep_schedule?: SleepSchedule;
  earliest_known_fossil?: Date;
}

Service definition example

// protos/squareup/dinosaurs/service.ts

import { ResponseStatus } from 'protos/squareup/common/response-status';
import { Dinosaur } from 'protos/squareup/dinosaurs/dinosaur';
import { Period } from 'protos/squareup/period/period';

export const requestUrl = '/1.0/dinosaurs/get-dinosaurs';

export interface GetDinosaursQuery {
  period?: Period;
  location?: Geolocation;
}

export interface GetDinosaursRequest {
  query?: GetDinosaursQuery;
}

export interface GetDinosaursResponse {
  status: ResponseStatus;
  dinosaurs: ReadonlyArray<Dinosaur>;
}

Usage

The output can be leveraged by consumers alongside libraries like Axios to minimize the level of effort to invoke service endpoints from browser and Node environments.

import axios from 'axios';
import type { AxiosResponse } from 'axios';
import type {
  GetDinosaursQuery,
  GetDinosaursRequest,
  GetDinosaursResponse,
} from 'protos/squareup/dinosaurs/service';
import { ResponseStatus } from 'protos/squareup/common/response-status';
import { requestUrl } from 'protos/squareup/dinosaurs/service';

/**
 * Get all dinosaurs in the system corresponding to the given query.
 */
export async function getDinosaurs(query: GetDinosaursQuery = {}) {
  const headers = { Accept: 'application/json' };
  const body: GetDinosaursRequest = { query };

  const response = await axios.post<
    GetDinosaursRequest,
    AxiosResponse<GetDinosaursResponse>
  >(requestUrl, body, { headers });

  if (response.status !== 200 || response.data.status !== ResponseStatus.OK) {
    // Handle error
  }

  return response.dinosaurs;
}
efirestone commented 2 years ago

To clarify, the desired output here is only type definitions, correct. Not actual proto byte serialization/deserialization?

If so, this feels different from the other first-party implementations within Wire which are complete solutions. Nothing wrong with that, but we may want to categorize it differently.

Two thoughts:

  1. We build a complete TypeScript solution, complete with serialization/deserialization, but offer a build flag to just output the types, or have a way to easily delete the implementation parts after-the-fact.
  2. We build the TypeScript type-only generation as a custom handler, but an official one that is packaged and tested with the rest of the Wire repo.

Given that (1) involves a lot of work that no one’s actually asking for right now, (2) feels like the better choice, and could be promoted to (1) in the future if needed. Only minor gotcha is that custom handlers are still technically beta and we should probably do whatever’s necessary to move them official first.

JakeWharton commented 2 years ago

I suspect it won't generate as nice of types, but simply compiling to JS will soon emit TS declarations: https://kotlinlang.org/docs/js-ir-compiler.html#preview-generation-of-typescript-declaration-files-d-ts

swankjesse commented 2 years ago

I think this is intended for use by JavaScript & TypeScript developers who aren’t otherwise using Kotlin or JVM tools. I think that’s a reasonable thing to add with the right person driving the work and design.

oldergod commented 2 years ago

A good use case for a CustomerHandler?

jerrypopsoff commented 2 years ago

To clarify, the desired output here is only type definitions, correct. Not actual proto byte serialization/deserialization?

Yes. Perhaps we can extend the initial solution to provide support for run-time code generation such as serialization/deserialization and type-checking if there is demand, but for now there are existing open source solutions available that can be leveraged by consumers to help accomplish this (if desired).

Additionally, the Android design explicitly omits serialization/deserialization as a design decision with reasoning that is equivalently applicable for TypeScript. From the Why Wire:

Wire avoids case mapping. A field declared as picture_urls in a schema yields a Java field picture_urls and not the conventional pictureUrls camel case. Though the name feels awkward at first, it's fantastic whenever you use grep or more sophisticated search tools. No more mapping when navigating between schema, Java source code, and data. It also provides a gentle reminder to calling code that proto messages are a bit special.

Overall I agree that the custom handler approach seems most reasonable, but I'm relatively new to Wire. @efirestone Is there an issue tracking the work to officially support custom handlers that are currently in beta? I'm seeing https://github.com/square/wire/issues/2023 but am unsure if this is sufficient for supporting the desired TypeScript code generation.

oldergod commented 2 years ago

You can now implementation your own TypeScript generator using custom handlers

efirestone commented 2 years ago

Also, I wrote a custom handler that generates TypeScript awhile back. It's likely using a slightly outdated custom handler API, but it should get you started.