Getting Started#
This guide provides a step-by-step walkthrough for setting up a basic client to interact with the Trip API. Follow the instructions in order to make requests. General gRPC and Protobuf knowledge is recommended for working with this example.
To familiarize yourself with gRPC or Protobuf, check out this introduction to gRPC and this introduction to Protobuf.
Requirements#
This guide requires curl and grpcurl to be installed. grpcurl will require the Protobuf definitions of the Trip API as input which we provide as a zip archive.
Getting Access#
The Trip API is only available to Integrators. If you are interested in becoming an Integrator, please contact us.
After your registration was processed, MOIA will provision credentials for evaluating / integrating with the Trip API.
Note
Evaluation Credentials Evaluation credentials are valid for a limited time period and will allow testing of the Trip API. With evaluation credentials you can immediately get started, following these steps.
Project setup#
We will use buf to generate a client for the Trip API. The commands below will set up a new project and install the required dependencies.
Create the project#
Download the Protobuf definitions trip_api.zip and create the project using the commands:
mkdir trip-client && cd trip-client
unzip your-downloads/trip_api.zip -d .
The zip archive includes:
Protobuf definitions of the Trip API,
Common types used in the Trip API.
Create buf configuration files#
With the following contents:
buf.gen.yaml
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
# replace with your module name
value: moia.io/tripapi-client
plugins:
- local: protoc-gen-go
out: ./
opt: paths=source_relative
include_imports: true
- local: protoc-gen-go-grpc
out: ./
opt: paths=source_relative
include_imports: true
- local: ./node_modules/ts-proto/protoc-gen-ts_proto
out: ./
strategy: all
opt:
- outputServices=generic-definitions
- outputServices=nice-grpc
- esModuleInterop=true
- useExactTypes=false
inputs:
- directory: protos
buf.yaml
version: v2
modules:
- path: ./protos/
buf.gen.yaml
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
# replace with your module name
value: moia.io/tripapi-client
plugins:
- local: protoc-gen-go
out: ./
opt: paths=source_relative
include_imports: true
- local: protoc-gen-go-grpc
out: ./
opt: paths=source_relative
include_imports: true
- local: ./node_modules/ts-proto/protoc-gen-ts_proto
out: ./
strategy: all
opt:
- outputServices=generic-definitions
- outputServices=nice-grpc
- esModuleInterop=true
- useExactTypes=false
inputs:
- directory: protos
We are using sbt as the build tool and ScalaPB to create the client files automatically on compilation.
Add the following to your project/plugins.sbt:
addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.8")
libraryDependencies += "com.thesamet.scalapb.zio-grpc" %% "zio-grpc-codegen" % "0.6.3"
Add the following to your build.sbt:
lazy val protobufSettings = Seq(
Compile / PB.protoSources := Seq(
(ThisBuild / baseDirectory).value / "protos"
),
Compile / PB.targets := Seq(
scalapb.gen(grpc = true) -> (Compile / sourceManaged).value / "scalapb",
scalapb.zio_grpc.ZioCodeGenerator -> (Compile / sourceManaged).value / "scalapb"
)
)
Generate the client code#
Run the following commands to install some dependencies and generate the client code:
go mod init moia.io/tripapi-client
go mod tidy
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
buf generate
npm init -y
tsc --init
npm install @bufbuild/buf nice-grpc ts-proto
npx buf generate
sbt compile
Create the client file and run it#
touch client.go
CLIENT_ID=<your-client-id> CLIENT_SECRET=<your-client-secret>
API_URL=<url> go run client.go
We will use some dependencies during this tutorial, depending on your setup you might need to repeatedly run go mod tidy or go get in order to install them.
We can use tsx to quickly run our TypeScript code.
touch client.ts
CLIENT_ID=<your-client-id> CLIENT_SECRET=<your-client-secret>
API_URL=<url> npx tsx client.ts
We can use sbt to quickly run our Scala code.
touch client.scala
CLIENT_ID=<your-client-id> CLIENT_SECRET=<your-client-secret>
API_URL=<url> sbt run
File Structure#
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"slices"
"time"
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/retry"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/oauth"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
authenticationv1 "moia.io/tripapi-client/moia/trip/authentication/v1"
customerv1 "moia.io/tripapi-client/moia/trip/customer/v1"
tripv1 "moia.io/tripapi-client/moia/trip/state/v1"
types_v1 "moia.io/tripapi-client/moia/type/v1"
)
var tracer = otel.Tracer("trip-api-client/trip/")
func main() {
ctx, span := tracer.Start(context.Background(), "main")
defer span.End()
// The code of the next steps goes here
}
import { type Span, trace } from "@opentelemetry/api";
import * as grpc from "nice-grpc";
import { retryMiddleware } from "nice-grpc-client-middleware-retry";
import { openTelemetryClientMiddleware } from "nice-grpc-opentelemetry";
import { BoardingAuthenticationServiceDefinition } from "../../../moia/trip/authentication/v1/authentication";
import { CustomerServiceDefinition } from "../../../moia/trip/customer/v1/customer";
import {
ApproachSpeed,
ChildSeat,
TripStateServiceDefinition,
Wheelchair,
} from "../../../moia/trip/state/v1/trip_state";
import { TokenManager } from "../../common/ts/TokenManager";
const tracer = trace.getTracer("trip-api-client/trip/");
async function main() {
await tracer.startActiveSpan("main", async (span: Span) => {
// The code of the next steps goes here
}
main();
import zio.*
import zio.json.*
import io.grpc.*
import scalapb.zio_grpc.{ZManagedChannel, SafeMetadata}
import java.net.URI
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.time.Instant
import java.util.Base64
final case class AppConfig(
clientId: String,
clientSecret: String,
uri: URI
):
val tokenUri: Task[URI] = ZIO.attempt(URI.create(s"$uri/auth/oauth/token"))
object AppConfig:
def make(): Task[AppConfig] =
for
clientId <- ZIO.fromOption(sys.env.get("CLIENT_ID")).mapError(_ => new Throwable("CLIENT_ID not set"))
clientSecret <- ZIO.fromOption(sys.env.get("CLIENT_SECRET")).mapError(_ => new Throwable("CLIENT_SECRET not set"))
apiUrl <- ZIO.fromOption(sys.env.get("API_URL")).mapError(_ => new Throwable("API_URL not set"))
apiUri <- ZIO.attempt(URI.create(s"https://$apiUrl"))
yield AppConfig(clientId, clientSecret, apiUri)
// The code of the next steps goes here
Authentication#
We use OAuth2 for authentication and authorization. You can retrieve an API token using the client credentials flow. The OAuth2 endpoint is accessible exclusively via HTTP/2.
Retrieve the API access token using the following request providing the credentials using the Authorization header. Replace $CLIENT_ID and $CLIENT_SECRET with the credentials provided.
You will need the $API_URL for all subsequent requests. You can set it as an environment variable for convenience. Replace <url> with the URL of the Trip API, taken from the introduction.
export API_URL=<url>
curl \
--http2 \
--request POST \
--location "https://$API_URL/auth/oauth/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--user "$CLIENT_ID:$CLIENT_SECRET" \
--data-urlencode "grant_type=client_credentials"
Example output:
{
"access_token": "eyJvcmciOiJkZWZhdWx0IiwiaWQiOiI2NzA1OGNiYTYxZjc0Kzc2OGQyMDk0NWJmNzI2ZmY2IiwiaCI6Im13bXVyMTI4In0=",
"expires_in": 3600,
"token_type": "bearer"
}
You will need your <ACCESS_TOKEN> for all subsequent requests. You can set the token as an environment variable for convenience. Replace <ACCESS_TOKEN> with the token value you received in the previous step.
export ACCESS_TOKEN=<ACCESS_TOKEN>
To obtain a token, we will use the golang.org/x/oauth2 library. You can also use an OAuth2 client, as long as you authenticate with the client_credentials grant type and use the authorization header with the Basic scheme.
clientId := os.Getenv("CLIENT_ID")
clientSecret := os.Getenv("CLIENT_SECRET")
apiUrl := os.Getenv("API_URL")
tokenUrl := fmt.Sprintf("https://%s/auth/oauth/token", apiUrl)
oauthConfig := clientcredentials.Config{
ClientID: clientId,
ClientSecret: clientSecret,
TokenURL: tokenUrl,
AuthStyle: oauth2.AuthStyleInHeader,
}
httpClient := &http.Client{Timeout: 30 * time.Second}
httpCtx := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Used in the next steps
tokenSource := oauthConfig.TokenSource(httpCtx)
To obtain a token we will send an HTTP request to the token URL using the fetch API. We can also use an OAuth2 client, as long as you authenticate with the grant type client_credentials and use the Authorization header with the Basic scheme.
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const apiUrl = process.env.API_URL;
if (!clientId || !clientSecret || !apiUrl) {
throw new Error(
"Missing required environment variables: CLIENT_ID, CLIENT_SECRET, API_URL",
);
}
const tokenManager = new TokenManager(clientId, clientSecret, apiUrl);
We define a re-usable TokenManager in another file:
import { connect as http2_connect } from "node:http2";
interface TokenResponse {
access_token: string;
expires_in: number;
}
export class TokenManager {
private accessToken: string | null = null;
private expiresAt: number = 0;
private refreshPromise: Promise<void> | null = null;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly tokenUrl: string;
constructor(clientId: string, clientSecret: string, apiUrl: string) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenUrl = `https://${apiUrl}/auth/oauth/token`;
}
private isTokenExpired(): boolean {
return Date.now() >= this.expiresAt - 30000; // 30 seconds buffer
}
private async fetchToken(): Promise<void> {
const http2AuthClient = http2_connect(this.tokenUrl);
// We must fail the application if the authentication fails but release the resource
try {
const authHeader = Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64");
const tokenRequest = http2AuthClient.request({
":method": "POST",
":path": "/auth/oauth/token",
authorization: `Basic ${authHeader}`,
"content-type": "application/x-www-form-urlencoded",
});
tokenRequest.setEncoding("utf8");
tokenRequest.write("grant_type=client_credentials");
tokenRequest.end();
let data = "";
for await (const chunk of tokenRequest) {
data += chunk;
}
// unsafe, will fail the Promise if the response does not match the TokenResponse JSON interface
const tokenResponse = JSON.parse(data) as TokenResponse;
this.accessToken = tokenResponse.access_token;
this.expiresAt = Date.now() + tokenResponse.expires_in * 1000;
} finally {
http2AuthClient.close();
}
}
async getAccessToken(): Promise<string> {
if (!this.accessToken || this.isTokenExpired()) {
if (!this.refreshPromise) {
this.refreshPromise = this.fetchToken().finally(() => {
this.refreshPromise = null;
});
}
await this.refreshPromise;
}
if (!this.accessToken) {
throw new Error("Failed to obtain access token");
}
return this.accessToken;
}
}
In this example we are using the ZIO library in Scala.
To obtain a token we will send an HTTP request to the token URL with the grant type client_credentials.
final case class AccessToken(token: String, expiresAt: Instant):
def isValid: Boolean = Instant.now().plusSeconds(30).isBefore(expiresAt)
trait AuthenticationService:
def requestAccessToken: Task[AccessToken]
object AuthenticationService:
final case class TokenResponse(access_token: String, token_type: String, expires_in: Int) derives JsonDecoder
def live(config: AppConfig): AuthenticationService = new AuthenticationService:
def requestAccessToken: Task[AccessToken] =
for
tokenResponse <- fetchTokenResponse
expiresAt = Instant.now().plusSeconds(tokenResponse.expires_in)
yield AccessToken(tokenResponse.access_token, expiresAt)
private def fetchTokenResponse: Task[TokenResponse] =
for
uri <- config.tokenUri
httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build()
credentials = Base64.getEncoder.encodeToString(s"${config.clientId}:${config.clientSecret}".getBytes)
requestBody = "grant_type=client_credentials"
request = HttpRequest
.newBuilder()
.uri(uri)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", s"Basic $credentials")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()
response <- ZIO.attemptBlocking(httpClient.send(request, HttpResponse.BodyHandlers.ofString()))
tokenResponse <- ZIO
.fromEither(response.body().fromJson[TokenResponse])
.mapError(error => new Throwable(s"Failed to parse token response: $error"))
yield tokenResponse
trait TokenManager:
def getAccessToken(): Task[String]
object TokenManager:
def live(authService: AuthenticationService): ZIO[Any, Nothing, TokenManager] =
for tokenRef <- Ref.make[Option[AccessToken]](None)
yield new TokenManager:
def getAccessToken(): Task[String] =
for
currentToken <- tokenRef.get
tokenOption = currentToken.filter(_.isValid).map(_.token)
token <- ZIO.fromOption(tokenOption).orElse(refreshToken)
yield token
private def refreshToken: Task[String] =
for
newToken <- authService.requestAccessToken
_ <- tokenRef.set(Some(newToken))
_ <- ZIO.logInfo("Access token refreshed")
yield newToken.token
Note
Authentication Problems In cases when a 401 error is repeatedly returned, please retry the authentication steps. If this problem persists, please contact us as there may be a problem with your credentials.
The token is authorized for all operations on the Trip API. It provides read and write data access on all resources. Responses must be filtered by Integrators in order to apply more granular data access policies.
The token’s expires_in specifies the remaining validity in seconds.
To authorize operations on behalf of a Customer we additionally require the correct Customer-Id in the request header. Operations for which this is required will be marked as a customer-scope endpoint in the API Reference.
The following table summarizes the request headers required for authorization:
Header | Type | Required | Description |
|---|---|---|---|
| String | Yes | The OAuth 2.0 bearer token to authorize the request for accessing the Trip API. For example: |
| String | Only for customer-scope endpoints | The Customer ID on behalf of whom an operation is requested. For example: |
Connect to the Trip API#
To place an API call with grpcurl set the following options:
Provide the Bearer token for authorization in the header with
-H "Authorization: Bearer <ACCESS_TOKEN>Only for customer-scope endpoints: Provide the Customer ID in the metadata with
-H "Customer-Id: <CUSTOMER_ID>"Provide the paths in which the protobuf definitions are located, e.g.
-import-path <PATH_TO_TRIP_API_PROTOS>. Make sure that all required definitions are located at this path.Provide the JSON formatted request data with
-d '<JSON_FORMATTED_REQUEST>'State the name of the protobuf source file for the corresponding gRPC service:
-proto moia/trip/service_area/v1/service_area.protofor Service Area Service
State the name of the protobuf source file for the standard gRPC error details definitions:
-proto google/rpc/error_details.proto
For convenience, set
-emit-defaultsto show default values in the responses.
Taking into considerations all of the above, this represents a sample grpcurl command:
grpcurl \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-import-path ./protos/ \
-proto "moia/trip/servicearea/v1/service_area.proto" \
-proto "google/rpc/error_details.proto" \
"$API_URL" moia.trip.servicearea.v1.ServiceAreaService/ListServiceAreas
Note
TLS
Trip API uses TLS for transport encryption. grpcurl enables TLS by default. If you use another client to evaluate the API make sure TLS is enabled.
We setup SSL credentials for transport and supply our token with every request using grpc.WithPerRPCCredentials.
perRpcCredentials := oauth.TokenSource{
TokenSource: oauth2.ReuseTokenSource(nil, tokenSource),
}
conn, err := grpc.NewClient(
apiUrl,
grpc.WithTransportCredentials(
credentials.NewClientTLSFromCert(nil, ""),
),
grpc.WithPerRPCCredentials(perRpcCredentials),
grpc.WithUnaryInterceptor(grpc_retry.UnaryClientInterceptor(retryOptions...)),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
log.Fatalf("Could not connect to %s: %v", apiUrl, err)
}
defer func() {
if err = conn.Close(); err != nil {
log.Printf("Error closing connection: %v", err)
}
}()
// Used for printing responses
jsonMarshalOptions := protojson.MarshalOptions{
Multiline: true,
EmitUnpopulated: true,
}
We use nice-grpc to set up a gRPC channel and client. We use the grpc.Metadata class to add the Authorization header to all of our gRPC requests.
// client Factory with middleware to insert authorization token to every request
const clientFactory = grpc
.createClientFactory()
.use(openTelemetryClientMiddleware())
.use(async function* (call, options) {
const token = await tokenManager.getAccessToken();
return yield* call.next(call.request, {
...options,
metadata: grpc
.Metadata(options.metadata)
.set("Authorization", `Bearer ${token}`),
});
})
.use(retryMiddleware);
const channel = grpc.createChannel(
apiUrl,
grpc.ChannelCredentials.createSsl(),
);
We use zio-grpc to set up a gRPC channel and client. We use the SafeMetadata to add the Authorization header to all of our gRPC requests.
trait GrpcClientService:
def createManagedChannel(): ZManagedChannel
object GrpcClientService:
def createAuthMetadata(accessToken: String, customerId: Option[String] = None): UIO[SafeMetadata] =
val metadata = List(
Some("Authorization" -> s"Bearer $accessToken"),
customerId.map { id => "Customer-Id" -> id }
).flatten
SafeMetadata.make(metadata*)
def live(config: AppConfig): GrpcClientService = new GrpcClientService:
def createManagedChannel(): ZManagedChannel =
val builder = ManagedChannelBuilder.forAddress(config.uri.getHost, config.uri.getPort).useTransportSecurity()
ZManagedChannel(builder)