Tapir vs Endpoints4s

Tapir vs Endpoints4s – The battle of the endpoints definition!

Tapir vs Endpoints4s

In this article, we compare Tapir with endpoints4s. We highlight the differences by providing examples and explanations for the most common features you would like to have in your REST API. Both libraries only require you to describe the communication protocol in Scala. Once the communication protocol is written, you need to wire it with a specific HTTP Server (such as  Akka HTTP) and/or body parsing (e.g. Circe). At the end, the library produces clients, documentation, and servers for you with implementations of your choices. 

Want to know how to do it? The pros and cons we see in them? Who won the battle? 

Here we go:

In the red corner; Tapir

Description

Tapir or Typed API descRiptions is a library that allows HTTP API endpoints to be described as immutable Scala values. This definition can be then integrated as a client, server or documentation.

Tapir currently supports Akka HTTP, Http4s and Finatra as a server; sttp as a client; OpenAPI as documentation.

The main advantage is that the documentation will be updated automatically when the endpoint is changed. Additionally, the protocol definition is very clear and easy to find, which in turn means easy to maintain.

Endpoint type

Endpoint[I, E, O, +S]

Where:

I – The type of input parameters,

E – The type of error output,

O – The type of regular output,

S – The type of streams. Nothing if no streams are used.

Endpoint sample

As an example, we can define an endpoint to findPersonBySurname. We would like this endpoint to accept a surname (String) from the user and return either an error (PersonEndpointError) or an entity (Person). We are not going to use streams here, so S will be replaced with Nothing. As the result, we are going to have:

Endpoint[String, PersonEndpointError, Person, Nothing]

Partial endpoint type

Tapir also supports an option to define partial endpoints. This feature can be used to create an endpoint, where some logic is provided and some not e.g. authentication.

PartialServerEndpoint[U, I, E, O, +S, F[_]]

Where:

U – The type of partially transformed input,

I – The type of input parameters,

E – The type of error output,

O – The type of regular output,

S – The type of streams. Nothing if no streams are used,

F – The effect type used in the provided partial logic.

Partial endpoint sample

As an example, we can define a security endpoint. Our logic for the authentication has the following signature def auth(token: Option[String]): Future[Either[ApiError, String]] – this accepts an optional token from the request and returns a Future of Either an error (ApiError) or an authenticated api key (String). As a result, we will have:

PartialServerEndpoint[ApiKey, Unit, ApiError, Unit, Nothing, Future]

Error type

It is important to know that the type of error in a PartialServerEndpoint implementation will be inherited by all of the endpoints that will use it, and there is no possibility of changing it later. Therefore, this type needs to be as wide as possible to collect all of the errors we want. 

Status code

Tapir provides an option to bind output with a status code via the and operator.

To return a single status code, we can run:

endpoint.errorOut(statusCode(StatusCode.Unauthorized).and(stringBody))

To return multiple status codes, we can run:

endpoint.errorOut(
oneOf[String](
  statusMapping(StatusCode.Unauthorized, stringBody),
  statusMapping(StatusCode.BadRequest, stringBody)
)
)

In the blue corner; Endpoints4s

Description

endpoints4s is a Scala library for remote communication. It ensures that the server, client, and documentation always agree on the same protocol. Due to the fact that the documentation will be updated when an endpoint is modified, maintenance will be much more simplified. In addition, errors are raised in compile-time.

Endpoints4s supports Akka HTTP, Play, and http4s as a server; Akka HTTP, Play, http4s, sttp, scalaj, and XMLHttpRequest (Scala.js) as a client; OpenAPI as documentation.

This library is highly extensible, so anyone can add new interpreters.

Endpoint type

Endpoint[A, B]

Where:

A – The type of input parameters,

B – The type of output parameters

Let’s define the same findPersonBySurname endpoint we have for Tapir with the same assumptions. We want to have a surname (String) as an input parameter and we want to return Either an error (PersonEndpointError) or an entity (Person). As a result, we will have:

Endpoint[String, Either[PersonEndpointError, Person]]

Round 1. Defining endpoints

Tapir

In Tapir, to create new endpoints we need to access the endpoint value and shape the endpoint in a way we want by calling available methods. The method jsonBody is provided by the import sttp.tapir.json.circe._ package, which is a bridge between Tapir and Circe. By using this, we have all of the features that Circe provides. This means we can use AutoDerivation from the io.circe.generic.auto._ package to convert our entities from/to Json format. Errors in Tapir by default will be returned with a 400 BadRequest status code. The toRoute method is provided by the import sttp.tapir.server.akkahttp._ package, which allows us to convert Tapir’s endpoint to an Akka HTTP Route and use it in the same way you use any other Akka HTTP feature.

Snippet

import io.circe.generic.auto._
import akka.http.scaladsl.server.Route
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.server.akkahttp._

trait ApartmentsEndpointsDefinition {

val listApartments: Endpoint[Unit, String, List[Apartment], Nothing] =
  endpoint
    .get
    .in("v1" / "data" / "apartments")
    .errorOut(stringBody.description("An error message, when something went wrong"))
    .out(
      jsonBody[List[Apartment]]
        .description("A list of apartments")
        .example(List(
          Apartment(Some(100), "Poznan", "Gorna Wilda", "3", "City of Poznan", 250000),
          Apartment(Some(101), "Warsaw", "Marszalkowska", "1", "City of Warsaw", 500000)
        ))
    )
    .description("An endpoint responsible for listing all available apartments")

val listApartmentsRoute: Route = listApartments.serverLogic(_ => storage.list()).toRoute
}

Endpoints4s

In Endpoints4s, to create new endpoints we need to use the endpoint(request, response, doc) method. Then, we need to create a request. We can use the request(method, url, entity, docs, headers) or get(url, docs, headers) methods to do this. Later, we will need to create a response. We can use the response(statusCode, entity, docs, headers) or ok(entity, docs, headers) methods. To convert our entities from/to Json, we can use jsonResponse which implicitly requires JsonSchema. Endpoints4s provides a generic way to create this. To convert our endpoint’s definition into the Akka HTTP Route, we are going to use the implementedByAsync method provided by endpoints4s.akkahttp.server.Endpoints

Snippet

import endpoints4s.{algebra, generic}

trait ApartmentsEndpointsDefinition extends algebra.Endpoints with algebra.JsonEntitiesFromSchemas with generic.JsonSchemas {
implicit val apartmentSchema: JsonSchema[Apartment] = genericJsonSchema

val listApartments: Endpoint[Unit, Either[String, List[Apartment]]] =
  endpoint(
    request = get(path / "v1" / "data" / "apartments"),
    response =
      response(BadRequest, textResponse, Some("An error message, when something went wrong"))
        .orElse(ok(jsonResponse[List[Apartment]], Some("A list of apartments"))),
    docs = EndpointDocs().withDescription(Some("An endpoint responsible for listing all available apartments"))
  )
}

import endpoints4s.akkahttp.server.{BuiltInErrors, Endpoints, JsonEntitiesFromSchemas}

trait ApartmentsEndpointsServer extends Definition with Endpoints with BuiltInErrors with JsonEntitiesFromSchemas {
val listApartmentsRoute: Route = listApartments.implementedByAsync(_ => storage.list())
}

Conclusion

In Tapir, we mostly import features instead of extending generic traits. The reason is in the different design of the Endpoints4s library. It is a highly extensible description language. This means that new interpreters can be added easily and you will receive compilation errors if your interpreter does not support all of the features required by Endpoints4s. A drawback is that, at the level of defining endpoints, everything is generic, which needs to be wired with specific implementation. This requires a lot of traits to be mixed together and a lot of mistakes can be made along the way (such as  forgetting about extending some traits). In Tapir, endpoints description language is implemented as AST which makes creating partial interpreters or extending the endpoints description language hard or even impossible. The endpoint type from Tapir, contains more generics, for everything you may expect. Endpoints4s, on the other hand helps us manage endpoint types, by having methods such as responseA.orElse(responseB) or response.orNotFound(), which changes the output type automatically by wrapping it into Either or Option.

Round 2. Generating documentation

Tapir

In Tapir, to expose documentation, we need to collect all of the endpoints definitions and convert them into an OpenAPI entity. Once this is converted we can use the SwaggerAkka class to create a route for exposing Swagger UI. UI allows us to test endpoints. To do this, we need to expose all of the documented routes too.

Snippet

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import sttp.tapir.docs.openapi._
import sttp.tapir.openapi.OpenAPI
import sttp.tapir.openapi.circe.yaml._
import sttp.tapir.swagger.akkahttp.SwaggerAkka

import scala.io.StdIn

object ApartmentsApi extends App {

private implicit val actorSystem: ActorSystem = ActorSystem()

private val openApiDocs: OpenAPI = List(listApartments)
  .toOpenAPI("The Apartments API", "1.0.0")

private val routes = concat(
  listApartmentsRoute,
  new SwaggerAkka(openApiDocs.toYaml).routes
)

val bindingFuture = Http().newServerAt("localhost", 8090).bind(routes)

println("Go to: http://localhost:8090/docs")
println("Press any key to exit ...")
StdIn.readLine()

}

How to run it

Once you have the above source code cloned, simply type sbt runTapirApi in the console. A specific alias has been already provided. The UI will be exposed at localhost:8090.

Endpoints4s

In Endpoints4s, to expose documentation we need to follow the same logic we followed for Tapir. Endpoints4s does not provide any code to expose Swagger UI automatically. We need to download and expose the Swagger UI code ourselves. Once this is done, we can expose all of the endpoints to have them testable from the UI.

Snippet

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import endpoints4s.akkahttp.server
import endpoints4s.openapi
import endpoints4s.openapi.model.{Info, OpenApi}

import scala.io.StdIn

object ApartmentsApi extends App {

private implicit val actorSystem: ActorSystem = ActorSystem()

private object DocumentedEndpoints extends ApartmentsEndpointsDefinition with openapi.Endpoints with openapi.JsonEntitiesFromSchemas with openapi.JsonSchemas {
  lazy val api: OpenApi = openApi(Info("The Apartments API", "1.0.0"))(listApartments)
}

private object DocumentationServer extends server.Endpoints with server.JsonEntitiesFromEncodersAndDecoders {

  private val openApiRoute: Route =
    endpoint(get(path / "openapi.json"), ok(jsonResponse[OpenApi]))
      .implementedBy(_ => DocumentedEndpoints.api)

  val docs: Route = concat(
    pathPrefix("docs") {
      pathEndOrSingleSlash {
        getFromResource("web/index.html")
      }
    },
    pathPrefix("swagger-ui") {
      getFromResourceDirectory("web/swagger-ui/")
    },
    openApiRoute
  )
}

val routes: Route = concat(listApartments, DocumentationServer.docs)
val bindingFuture = Http().newServerAt("localhost", 8080).bind(routes)

println("Go to: http://localhost:8080/docs")
println("Press any key to exit ...")
StdIn.readLine()

}

How to run it

Once you have the above source code cloned, simply type sbt runEndpoints4sApi in the console. A specific alias has been already provided. The UI will be exposed at localhost:8080.

Conclusion

Both libraries provide mechanisms for exposing documentation out of the already created endpoints. In the case of Tapir, the entire procedure requires less code for exposing Swagger UI (SwaggerAkka). In the case of Endpoints4s the entire procedure is more complicated. We need to extend openapi classes for the endpoints and json support. We also need to take care of Swagger UI and route the definition by ourselves.

Round 3. Adding authentication

Tapir

In Tapir, we can use a feature called PartialEndpointService, already mentioned in the introduction of this library. A token can be taken from the “api-key” header and passed to the authenticate method, which will be implemented at a higher level.

Snippet

import sttp.model.StatusCode.Unauthorized
import sttp.tapir._
import sttp.tapir.server._

trait SecuritySupport[F[_]] {

def authenticate(token: Option[String]): F[Either[String, ApiKey]]

val securedEndpoint: PartialServerEndpoint[ApiKey, Unit, String, Unit, Nothing, F] = endpoint
  .in(auth.apiKey(header[Option[String]]("api-key")))
  .errorOut(statusCode(Unauthorized).and(stringBody.description("An error message when authentication failed")))
  .serverLogicForCurrent(authenticate)

}

Open API documentation

Due to the lack of implicit conversion for PartialServerEndpoint into Open API, we need to provide it ourselves. The implementation can be found here. Don’t forget to import it in the right place! 

Status code

Currently, our PartialServerEndpoint will always return a 401 Unauthorized status code, even in the example of missing entities. This will be improved in the “Error Handling” section.

Endpoints4s

In Endpoints4s, we are going to implement a new method to create an endpoint with an authentication mechanism. Then, we will simply call it in the endpoints definition.

Snippet

import endpoints4s.Tupler
import endpoints4s.algebra.Endpoints

trait SecuritySupport extends Endpoints {

def authenticatedRequest[I, O](request: Request[I])(implicit tupler: Tupler.Aux[I, ApiKey, O]): Request[O]

final def authenticatedEndpoint[U, O, I](request: Request[U],
                                          response: Response[O],
                                          docs: EndpointDocs = EndpointDocs()
                                        )(implicit tupler: Tupler.Aux[U, ApiKey, I]): Endpoint[I, O] =
  endpoint(
    authenticatedRequest(request),
    response,
    docs
  )
}

Authenticated method

Due to the fact that the returned type for authenticatedRequest is Request, which in Akka HTTP will be mapped to Directive0, we need to provide an implementation for this directive ourselves. We will be asked to collect all of the header values we need and ask for authentication using Akka HTTP mechanisms. You can find how we did it here.

Conclusion

The main difference between these two libraries is that in Tapir, we can add support for authentication using much less code, than in Endpoints4s which, at the same time, is less complicated. PartialServerEndpoint helps with this. It creates a clear definition of the authenticated endpoint. The only real change we need to do is to implement an authenticate method. In Endpoints4s we need to extract the API-key from the header first in the library of your choice (Akka HTTP in our case), check if the token is valid or not, and return a specific response via the complete method. This gives us more flexibility but also requires more code and to have some knowledge of Akka HTTP.

Round 4. Query parameters with validation

Tapir

In Tapir, we can map multiple query string parameters into a single case class using the mapTo method. Keep in mind that the order of fields in the case class needs to be in line with the fields in a query string. To add validation, we can use the validate method, which expects Validator[T] to be provided. Tapir provides an easy way to add custom validators.

Snippet

import sttp.tapir._
import sttp.tapir.Validator._

trait QueryStringParams {
private val pagingFrom = query[Int]("from")
  .description("Indicates where we should start returning data from")
  .example(1)
private val pagingLimit = query[Option[Int]]("limit")
  .description("An optional number of rows to be returned")
  .example(Some(100))

val pagingIn: EndpointInput[Paging] =
  (pagingFrom / pagingLimit).mapTo(Paging).validate(pagingValidator)

private def pagingValidator: Validator[Paging] = Validator.all[Paging](
  greater(0, "from").contramap(_.from),
  max[Int](100).asOptionElement.contramap(_.limit),
  min[Int](0).asOptionElement.contramap(_.limit)
)

private def greater(n: Int, field: String): Validator[Int] =
  custom[Int](x => x >= n, s"Field: $field needs to be greater than $n")
}

Endpoints4s

In Endpoints4s to map query string parameters to specific case classes, we can use the xmapPartial method. The same method allows us to add validation during the mapping process. As a result of this, we need to return either Valid or Invalid.

Snippet

import endpoints4s.Validated
import endpoints4s.algebra.Endpoints

trait QueryStringParams extends Endpoints {

private val pagingFrom = qs[Int]("from", Some("Indicates where we should start returning data from"))
private val pagingLimit = qs[Option[Int]]("limit", Some("An optional number of rows to be returned"))

val pagingQueryString: QueryString[Paging] =
  (pagingFrom & pagingLimit).xmapPartial(toValidatedPaging)(x => (x.from, x.limit))

private def toValidatedPaging(x: (Int, Option[Int])): Validated[Paging] = x match {
  case (from, limit) =>
    val result = for {
      validPagingFrom <- validatePagingFrom(from)
      validPagingLimit <- validatePagingLimit(limit)
    } yield Paging(validPagingFrom, validPagingLimit)

    Validated.fromEither(result.left.map(Seq(_)))
}

private def validatePagingFrom(from: Int): Either[String, Int] =
  Either.cond(from >= 0, from, "Paging.from cannot be lower than 0")

private def validatePagingLimit(limit: Option[Int]): Either[String, Option[Int]] =
  Either.cond(limit.forall(l => 0 <= l && l <= 100), limit, "Paging.limit needs to be between 0 and 100 (both inclusive)")

}

Conclusion

As you can see, both libraries provide some feature to validate the query string parameters. In Tapir, we have a dedicated validate method, which requires Validator[T] to be provided. Basic validators are already provided in the library, so we can simply use them. Plus, there is an easy way of extending the set of validators. In Endpoints4s, we have an xmapPartial method to map a set of parameters into the case class and during this process we can also validate incoming parameters. Additionally, you won’t be able to find predefined validations in this library. You need to write them on your own.

Round 5. Handling errors

Tapir

In Tapir, we can specify the status code and its body using the statusMapping(statusCode, output) method. By running this with statusMapping(BadRequest, jsonBody[ApiError.BadRequest]) we can bind the status code with a specific output. If we want to define more than one error output for a single endpoint, we can use the oneOf method. These status codes will be reflected in the OpenAPI documentation. Once defined we only need to remember about returning the proper ApiError type from our endpoints implementation.

Snippet

import io.scalac.lab.api.model.Apartment
import sttp.tapir.EndpointOutput.StatusMapping
import sttp.tapir.json.circe.jsonBody
import sttp.tapir._
import sttp.model.StatusCode

class ApartmentsEndpointsDefinition {

sealed trait ApiError
case class BadRequestError() extends ApiError
case class NotFoundError() extends ApiError

val BadRequest: StatusMapping[BadRequestError] =
  statusMapping(StatusCode.BadRequest, jsonBody[BadRequestError].description("Api error returned when request could not be processed properly"))

val NotFound: StatusMapping[NotFoundError] =
  statusMapping(StatusCode.NotFound, jsonBody[NotFoundError].description("Api error returned when request could not be found"))

val listApartments: Endpoint[Int, ApiError, List[Apartment], Nothing] =
  endpoint.get
    .in("v1" / "data" / "apartments" / path[Int]("id"))
    .out(jsonBody[List[Apartment]])
    .errorOut(oneOf[ApiError](BadRequest, NotFound))

}

Endpoints4s

In Endpoints4s, things are getting more complicated. There is no built-in mechanism such as we had in Tapir for supporting multiple status codes for a single endpoint (there is no equivalent for oneOf). Gladly, we could introduce our own way of supporting this. We could wrap our responses with the author’s withStatusCodes(response, codes) method. This method could be then implemented in the documentation, where we will add specific documented responses and in production where we will handle specific codes.

Snippet

import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import endpoints4s.algebra
import endpoints4s.generic.JsonSchemas
import io.scalac.lab.api.model.{Apartment, ApiError}

trait ApartmentsEndpointsDefinition extends algebra.Endpoints with algebra.JsonEntitiesFromSchemas with JsonSchemas {

sealed trait ApiError
case class NotFoundError() extends ApiError
case class BadRequestError() extends ApiError

val listApartments: Endpoint[Unit, Either[ApiError, List[Apartment]]] =
  endpoint(
    request = get(path / "v1" / "data" / "apartments" / segment[Int]("id")),
    response = withStatusCodes(ok(jsonResponse[List[Apartment]], Some("A list of apartments")), NotFound, BadRequest),
    docs = EndpointDocs().withDescription(Some("An endpoint responsible for listing all available apartments"))
  )

def withStatusCodes[A](response: Response[A], codes: StatusCode*): Response[Either[ApiError, A]]

val badRequest: Response[BadRequestError] =
  response(BadRequest, jsonResponse[BadRequestError], Some("Api error returned when request could not be processed properly"))

val notFound: Response[NotFoundError] =
  response(NotFound, jsonResponse[NotFoundError], Some("Api error returned when entity could not be found"))

}

import endpoints4s.akkahttp.server
object ApartmentsEndpointsServer extends ApartmentsEndpointsDefinition with server.Endpoints with server.JsonEntitiesFromSchemas {
override def withStatusCodes[A](response: A => Route, codes: StatusCode*): Either[ApiError, A] => Route = {
  case Left(x @ ApiError.NotFoundError(_))     => complete(NotFound, x)
  case Left(x @ ApiError.BadRequestError(_))   => complete(BadRequest, x)
  case Right(value)                            => response(value)
}
}

import endpoints4s.openapi
object ApartmentsEndpointsDocumentation extends ApartmentsEndpointsDefinition with openapi.Endpoints with openapi.JsonEntitiesFromSchemas {
override def withStatusCodes[A](responses: List[DocumentedResponse], codes: Int*): List[DocumentedResponse] = {
  responses :++ codes.flatMap {
    case 400 => badRequest
    case 404 => notFound
  }
}
}

Conclusion

Due to the fact that Tapir has built-in mechanisms for handling multiple status codes in a single response, everything was easier and more intuitive. The Endpoints4s library does not support such mechanisms but it is flexible enough to add them. That being said, creating documentation automatically is what these two libraries are mostly chosen for, therefore more weight should be put on this.

… and the winner is …

Defining endpoints

Tapir provides ADT for building endpoints, which can be imported in the scope. Endpoints4s provides a truly generic way of defining endpoints. We need to extend the generic traits while defining the endpoints and wire them up with the backend-specific traits at the higher level. This can create a bit of confusion and looks really complex, at least for beginners, but is more powerful.

Generating documentation

The OpenAPI documentation created out of the endpoints definition is more accurate and the way of creating it is more intuitive in Tapir than in Endpoints4s. In Tapir, we can also provide examples – there is a dedicated method for this. In Endpoints4s we need to add missing features manually, while creating it.

Adding authentication

Tapir provides built-in mechanisms for adding authentication to the endpoints. Swagger UI is automatically adjusted to all of the changes. In Endpoints4s you need to implement features, such as the Authorize Button in Swagger UI to make the API testable, or authentication which is done partially in Akka HTTP by yourself.

Query parameters with validation

Both libraries provide mechanisms for mapping and validation of query string parameters. In Tapir we have a dedicated method for mapping and a different one for validation, while in Endpoints4s we can do both in a single method. In the end, we get pretty much the same results.

Handling errors

Again, Tapir provides built-in mechanisms for error handling, which had to be customized for our use case. In the other corner, Endpoints4s does not provide any and we need to reflect our error handling in the documentation completely in a custom way.

Documentation*

In the case of Tapir, you will be able to find good and accurate explanations of multiple mechanisms, structures, and ideas that have been implemented there (although not always). In terms of Endpoints4s, it is really hard to find good documentation and examples for the key mechanisms you would like to use.

Overall score

Everything written here leads to the conclusion that at this point in time, out of these two libraries, Tapir looks like a better choice for a new project.

Source code

You can browse our GitHub repository to see a working, complete solution with the snippets used in this blog post. Take a look at the different branches which represent different stages of development, which are connected with the rounds in this blog post. You can also just simply compare different branches to see the list of changes made at these stages.

Read also

Download e-book:

Scalac Case Study Book

Download now

Authors

Adrian Juszczak

I started my journey with Software Development over eight years ago. Starting from Backend Developer, throughout Frontend Developer, and finished as Data Engineer. I've worked in AdTech companies, where shuffling hundreds of gigabytes of the data daily. I'm always trying to stay up to date with new technologies, languages, tools, and frameworks, anything that can help me with solving Real World problems because that is what matters the most.

Latest Blogposts

17.04.2024 / By  Michał Szajkowski

Mocking Libraries can be your doom

Test Automations

Test automation is great. Nowadays, it’s become a crucial part of basically any software development process. And at the unit test level it is often a necessity to mimic a foreign service or other dependencies you want to isolate from. So in such a case, using a mock library should be an obvious choice that […]

04.04.2024 / By  Aleksander Rainko

Scala 3 Data Transformation Library: ducktape 0.2.0.

Scala 3 Data Transformation Library: Ducktape 2.0

Introduction: Is ducktape still all duct tape under the hood? Or, why are macros so cool that I’m basically rewriting it for the third time? Before I go off talking about the insides of the library, let’s first touch base on what ducktape actually is, its Github page describes it as this: Automatic and customizable […]

28.03.2024 / By  Matylda Kamińska

Scalendar April 2024

scala conferences april 2024

Event-driven Newsletter Another month full of packed events, not only around Scala conferences in April 2024 but also Frontend Development, and Software Architecture—all set to give you a treasure trove of learning and networking opportunities. There’re online and real-world events that you can join in order to meet colleagues and experts from all over the […]

software product development

Need a successful project?

Estimate project