Clients

mctl api ts, api dart, api kt and api java read the same spec the server came from and write the other side of the wire. One route becomes one method.

What a route becomes

For each route in the spec, in each language:

  • path: fields become parameters, in the order they appear in the path.
  • form: fields become query parameters.
  • header: fields become request headers.
  • the json: fields of the request type become the body.
  • the response type is what the method returns.

A call that answers outside 2xx raises — ApiError in TypeScript, ApiException in the other three — carrying the status and the body as it came back, because a 400 with a reason in it is more useful than a generic failure.

The transport is a port

None of the four clients ships a transport. Each takes one, which is why a generated file compiles with the standard library alone and a test can pass a function that returns a canned answer.

LanguageThe portDefault
TypeScriptFetch — (url, init) => Promise<HttpResponse>the global fetch
DartSend — (method, uri, headers, body) => Future<RawResponse>none: hand it package:http or a double
KotlinTransport — a fun interfacenone
JavaTransport — an interface with one methodnone
TypeScript
const api = new UserApiClient("https://api.example.com");
// or, with a token and your own fetch:
const api = new UserApiClient("https://api.example.com", myFetch, token);
Dart
final api = UserApiClient(
  baseUrl: 'https://api.example.com',
  send: (method, url, headers, body) async {
    final res = await http.request(method, url, headers: headers, body: body);
    return RawResponse(res.statusCode, await res.stream.bytesToString());
  },
);

One route, four languages

The same route — post /api/v1/login (LoginReq) returns (LoginResp) — as each generator wrote it. These are excerpts: the file also carries the model types and their JSON conversion.

user-api_client.tsexcerpt
export class UserApiClient {
  readonly baseUrl: string;
  token?: string;

  constructor(baseUrl: string, fetchImpl?: Fetch, token?: string) {
    this.baseUrl = baseUrl.replace(/\/+$/, "");
    this.fetchImpl = fetchImpl ?? ((url, init) => fetch(url, init));
    this.token = token;
  }

  /** log a user in */
  async login(body: LoginReq): Promise<LoginResp> {
    const path = `/api/v1/login`;
    const url = this.baseUrl + path;
    const headers: Record<string, string> = { accept: "application/json" };
    if (this.token !== undefined) {
      headers["authorization"] = "Bearer " + this.token;
    }
    headers["content-type"] = "application/json";
    const res = await this.fetchImpl(url, {
      method: "POST",
      headers,
      body: JSON.stringify(loginReqToJson(body)),
    });
    if (!res.ok) {
      throw new ApiError(res.status, await res.text());
    }
    return loginRespFromJson(JSON.parse(await res.text()));
  }

  async getProfile(id: string): Promise<Profile> {
    const path = `/api/v1/profile/${encodeURIComponent(String(id))}`;
    const url = this.baseUrl + path;
    const headers: Record<string, string> = { accept: "application/json" };
    if (this.token !== undefined) {
      headers["authorization"] = "Bearer " + this.token;
    }
    const res = await this.fetchImpl(url, { method: "GET", headers });
    if (!res.ok) {
      throw new ApiError(res.status, await res.text());
    }
    return profileFromJson(JSON.parse(await res.text()));
  }
}
user-api_client.dartexcerpt
class UserApiClient {
  UserApiClient({required String baseUrl, required this.send, this.token})
      : baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), '');

  final String baseUrl;
  final Send send;
  final String? token;

  Future<LoginResp> login(LoginReq body) async {
    final path = '/api/v1/login';
    final url = Uri.parse('$baseUrl$path');
    final headers = <String, String>{'accept': 'application/json'};
    if (token != null) {
      headers['authorization'] = 'Bearer $token';
    }
    final res = await send('POST', url, headers, jsonEncode(body.toJson()));
    if (!res.ok) {
      throw ApiException(res.status, res.body);
    }
    return LoginResp.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
  }

  Future<Profile> getProfile({required String id}) async {
    final path = '/api/v1/profile/${Uri.encodeComponent(id.toString())}';
    final url = Uri.parse('$baseUrl$path');
    final headers = <String, String>{'accept': 'application/json'};
    if (token != null) {
      headers['authorization'] = 'Bearer $token';
    }
    final res = await send('GET', url, headers, null);
    if (!res.ok) {
      throw ApiException(res.status, res.body);
    }
    return Profile.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
  }
}
user-api_client.ktexcerpt
class UserApiClient(
    private val baseUrl: String,
    private val transport: Transport,
    private val codec: Codec,
    private var token: String? = null,
) {
    fun login(body: LoginReq): LoginResp {
        val path = "/api/v1/login"
        val headers = linkedMapOf("accept" to "application/json")
        token?.let { headers["authorization"] = "Bearer " + it }
        val res = transport.send("POST", baseUrl + path, headers, codec.encode(body))
        if (!res.ok) {
            throw ApiException(res.status, res.body)
        }
        return LoginResp.fromWire(codec.decode(res.body))
    }

    fun getProfile(id: String): Profile {
        val path = "/api/v1/profile/" + enc(id)
        val headers = linkedMapOf("accept" to "application/json")
        token?.let { headers["authorization"] = "Bearer " + it }
        val res = transport.send("GET", baseUrl + path, headers, null)
        if (!res.ok) {
            throw ApiException(res.status, res.body)
        }
        return Profile.fromWire(codec.decode(res.body))
    }
}
UserApiClient.javaexcerpt
public final class UserApiClient {
    public Profile getProfile(String id) {
        String path = "/api/v1/profile/" + enc(id);
        String url = baseUrl + path;
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("accept", "application/json");
        if (token != null) {
            headers.put("authorization", "Bearer " + token);
        }
        RawResponse res = transport.send("GET", url, headers, null);
        if (!res.ok()) {
            throw new ApiException(res.status, res.body);
        }
        return Profile.fromWire(codec.decode(res.body));
    }
}

Type mapping

.apiTypeScriptDartKotlinJava
stringstringStringStringString
boolbooleanboolBooleanBoolean
intnumberintIntInteger
int64numberintLongLong
float64numberdoubleDoubleDouble
[]TT[]List<T>List<T>List<T>
map[K]VRecord<K, V>Map<K, V>Map<K, V>Map<K, V>
T optionalT | nullT?T?T, may be null

A path parameter is passed as text, because it goes into the URL: a path:"id" field typed int64 is a string in TypeScript and String in the other three, and the client percent-encodes it.

Checking the output

Each file is meant to compile under its own toolchain with no packages added. If you want to convince yourself before trusting it, these are the four commands:

terminal
$ tsc --strict --noEmit --target es2022 --lib es2022,dom user-api_client.ts
$ dart analyze user-api_client.dart
$ javac -Xlint:all -d out UserApiClient.java
$ kotlinc user-api_client.kt -d client.jar

Java needs its one public class to live in a file named after it, so api java writes UserApiClient.java rather than user-api_client.java. The other three are named <service>_client.<ext>.