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.
| Language | The port | Default |
|---|---|---|
| TypeScript | Fetch — (url, init) => Promise<HttpResponse> | the global fetch |
| Dart | Send — (method, uri, headers, body) => Future<RawResponse> | none: hand it package:http or a double |
| Kotlin | Transport — a fun interface | none |
| Java | Transport — an interface with one method | none |
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);
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.
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()));
}
}
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>);
}
}
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))
}
}
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
| .api | TypeScript | Dart | Kotlin | Java |
|---|---|---|---|---|
string | string | String | String | String |
bool | boolean | bool | Boolean | Boolean |
int | number | int | Int | Integer |
int64 | number | int | Long | Long |
float64 | number | double | Double | Double |
[]T | T[] | List<T> | List<T> | List<T> |
map[K]V | Record<K, V> | Map<K, V> | Map<K, V> | Map<K, V> |
T optional | T | null | T? | 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:
$ 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>.