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

Authorization

String

Yes

The OAuth 2.0 bearer token to authorize the request for accessing the Trip API. For example: Bearer <Access token>.

Customer-Id

String

Only for customer-scope endpoints

The Customer ID on behalf of whom an operation is requested. For example: <Customer-ID>.

Connect to the Trip API#

To place an API call with grpcurl set the following options:

  1. Provide the Bearer token for authorization in the header with -H "Authorization: Bearer <ACCESS_TOKEN>

  2. Only for customer-scope endpoints: Provide the Customer ID in the metadata with -H "Customer-Id: <CUSTOMER_ID>"

  3. 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.

  4. Provide the JSON formatted request data with -d '<JSON_FORMATTED_REQUEST>'

  5. State the name of the protobuf source file for the corresponding gRPC service:

  6. State the name of the protobuf source file for the standard gRPC error details definitions:

    • -proto google/rpc/error_details.proto

  7. For convenience, set -emit-defaults to 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)