├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── endpoints-common └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ ├── common │ │ ├── AuthorizedRequests.scala │ │ ├── CreateOrUpdateResult.scala │ │ ├── DateDeserializers.scala │ │ ├── EitherUnmarshalling.scala │ │ ├── HktMarshallable.scala │ │ ├── HttpLogging.scala │ │ ├── OcpiClient.scala │ │ ├── OcpiDirectives.scala │ │ ├── OcpiExceptionHandler.scala │ │ ├── OcpiRejectionHandler.scala │ │ ├── OcpiResponseUnmarshalling.scala │ │ ├── PaginatedRoute.scala │ │ ├── PaginatedSource.scala │ │ ├── TokenAuthenticator.scala │ │ └── package.scala │ │ ├── msgs │ │ └── package.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── common │ ├── ClientObjectUriSpec.scala │ ├── HktMarshallableFromECInstances.scala │ ├── IOMatchersExt.scala │ ├── OcpiClientSpec.scala │ ├── OcpiRejectionHandlerSpec.scala │ ├── PaginatedRouteSpec.scala │ └── PaginatedSourceSpec.scala ├── endpoints-cpo-locations └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── locations │ │ ├── CpoLocationsRoute.scala │ │ ├── CpoLocationsService.scala │ │ ├── LocationsError.scala │ │ └── MspLocationsClient.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── locations │ └── CpoLocationsRouteSpec.scala ├── endpoints-cpo-tokens └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── tokens │ │ ├── CpoTokensRoute.scala │ │ ├── CpoTokensService.scala │ │ ├── MspTokensClient.scala │ │ ├── TokenError.scala │ │ └── TokensClient.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── tokens │ ├── CpoTokensRouteSpec.scala │ └── MspTokensClientSpec.scala ├── endpoints-msp-cdrs └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── cdrs │ │ ├── CdrsClient.scala │ │ ├── CdrsError.scala │ │ ├── MspCdrsRoute.scala │ │ └── MspCdrsService.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── cdrs │ └── MspCdrsRouteSpec.scala ├── endpoints-msp-commands └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── commands │ │ ├── CommandClient.scala │ │ └── CommandResponseRoute.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── commands │ ├── CommandClientSpec.scala │ └── CommandResponseRouteSpec.scala ├── endpoints-msp-locations └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── locations │ │ ├── LocationsClient.scala │ │ ├── LocationsError.scala │ │ ├── MspLocationsRoute.scala │ │ └── MspLocationsService.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── locations │ └── MspLocationsRouteSpec.scala ├── endpoints-msp-sessions └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── sessions │ │ ├── SessionError.scala │ │ ├── SessionsClient.scala │ │ ├── SessionsRoute.scala │ │ └── SessionsService.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── sessions │ └── SessionsRouteSpec.scala ├── endpoints-msp-tokens └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── tokens │ │ ├── CpoTokensClient.scala │ │ ├── MspTokensRoute.scala │ │ └── MspTokensService.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── tokens │ ├── CpoTokensClientSpec.scala │ └── MspTokensRouteSpec.scala ├── endpoints-registration └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── registration │ │ ├── ErrorMarshalling.scala │ │ ├── RegistrationClient.scala │ │ ├── RegistrationError.scala │ │ ├── RegistrationRepo.scala │ │ ├── RegistrationRoute.scala │ │ └── RegistrationService.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── registration │ ├── RegistrationClientSpec.scala │ ├── RegistrationRouteSpec.scala │ └── RegistrationServiceSpec.scala ├── endpoints-versions └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ ├── VersionRejections.scala │ │ └── VersionsRoute.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── VersionsRouteSpec.scala ├── example └── src │ └── main │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── example │ ├── ExampleApp.scala │ └── ExampleCatsIO.scala ├── msgs-circe └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── msgs │ │ └── circe │ │ └── v2_1 │ │ ├── CdrsJsonProtocol.scala │ │ ├── CommandsJsonProtocol.scala │ │ ├── CommonJsonProtocol.scala │ │ ├── CredentialsJsonProtocol.scala │ │ ├── LocationsJsonProtocol.scala │ │ ├── SessionJsonProtocol.scala │ │ ├── TariffsJsonProtocol.scala │ │ ├── TokensJsonProtocol.scala │ │ ├── VersionsJsonProtocol.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── msgs │ └── circe │ └── v2_1 │ ├── CdrsSpec.scala │ ├── CirceJsonSpec.scala │ ├── CommonTypesSpec.scala │ ├── CredentialsSpecs.scala │ ├── LocationsSpecs.scala │ ├── SessionsSpecs.scala │ ├── TariffsSpec.scala │ ├── TokensSpec.scala │ └── VersionsSpec.scala ├── msgs-json-test └── src │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── msgs │ └── v2_1 │ ├── GenericCdrsSpec.scala │ ├── GenericCommonTypesSpec.scala │ ├── GenericCredentialsSpec.scala │ ├── GenericJsonSpec.scala │ ├── GenericLocationsSpec.scala │ ├── GenericSessionsSpec.scala │ ├── GenericTariffsSpec.scala │ ├── GenericTokensSpec.scala │ └── GenericVersionsSpec.scala ├── msgs-shapeless └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── msgs │ │ └── shapeless │ │ └── MergePatch.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── msgs │ └── shapeless │ └── MergePatchSpec.scala ├── msgs-spray-json └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ └── msgs │ │ └── sprayjson │ │ ├── SimpleStringEnumSerializer.scala │ │ └── v2_1 │ │ ├── CdrsJsonProtocol.scala │ │ ├── CommandsJsonProtocol.scala │ │ ├── CredentialsJsonProtocol.scala │ │ ├── DefaultJsonProtocol.scala │ │ ├── LocationsJsonProtocol.scala │ │ ├── SessionJsonProtocol.scala │ │ ├── TariffsJsonProtocol.scala │ │ ├── TokensJsonProtocol.scala │ │ ├── VersionsJsonProtocol.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ └── msgs │ └── sprayjson │ └── v2_1 │ ├── CdrsSpec.scala │ ├── CommonTypesSpec.scala │ ├── CredentialsSpecs.scala │ ├── LocationsSpecs.scala │ ├── SessionsSpec.scala │ ├── SprayJsonSpec.scala │ ├── TariffsSpec.scala │ ├── TokensSpec.scala │ └── VersionsSpec.scala ├── msgs └── src │ ├── main │ └── scala │ │ └── com │ │ └── thenewmotion │ │ └── ocpi │ │ ├── dateTimeParsing.scala │ │ └── msgs │ │ ├── GlobalPartyId.scala │ │ ├── OcpiStatusCode.scala │ │ ├── Versions.scala │ │ ├── common.scala │ │ ├── resource.scala │ │ ├── response.scala │ │ └── v2_1 │ │ ├── Cdrs.scala │ │ ├── Commands.scala │ │ ├── CommonTypes.scala │ │ ├── Credentials.scala │ │ ├── Locations.scala │ │ ├── Sessions.scala │ │ ├── Tariffs.scala │ │ ├── Tokens.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── thenewmotion │ └── ocpi │ ├── OcpiDateTimeParserSpec.scala │ └── msgs │ ├── CdrIdSpec.scala │ ├── GlobalPartyIdSpec.scala │ ├── VersionNumberSpec.scala │ └── v2_1 │ ├── CommandsSpec.scala │ ├── LocationsSpec.scala │ └── SessionsSpec.scala ├── prelude └── src │ └── main │ └── scala │ ├── Enumerable.scala │ └── Nameable.scala ├── project ├── build.properties └── plugins.sbt └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | # Emacs 2 | *~ 3 | *# 4 | .#* 5 | .ensime 6 | .ensime_cache 7 | TAGS 8 | .tern-port 9 | .dir-locals.el 10 | 11 | dev/ 12 | 13 | /.idea/ 14 | /.idea_modules/ 15 | /project/boot/ 16 | /project/plugins/project 17 | /project/target/** 18 | /project/project/target/** 19 | target/ 20 | **/target/** 21 | lib_managed/ 22 | src_managed/ 23 | test-output/ 24 | *.iml 25 | .classpath_nb 26 | 27 | #sbt 28 | .bsp 29 | .history 30 | .bsp 31 | 32 | #logs 33 | import.log 34 | 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | dist: trusty 3 | scala: 4 | - 2.12.12 5 | - 2.13.4 6 | jdk: 7 | - oraclejdk8 8 | script: sbt ++$TRAVIS_SCALA_VERSION clean test 9 | notifications: 10 | slack: thenewmotion:AXSluctig1mmZbeKRDeeY41s 11 | cache: 12 | directories: 13 | - $HOME/.sbt 14 | - $HOME/.ivy2/cache 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Copyright (c) 2014 The New Motion team, and respective contributors 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | use this file except in compliance with the License. You may obtain a copy of 7 | the License at 8 | 9 | [http://www.apache.org/licenses/LICENSE-2.0] 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | License for the specific language governing permissions and limitations under 15 | the License. -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/AuthorizedRequests.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import scala.concurrent.{ExecutionContext, Future} 4 | import akka.http.scaladsl.HttpExt 5 | import akka.http.scaladsl.model.headers.{GenericHttpCredentials, Location} 6 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} 7 | import akka.stream.Materializer 8 | import com.thenewmotion.ocpi.Logger 9 | import com.thenewmotion.ocpi.msgs.AuthToken 10 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 11 | 12 | trait AuthorizedRequests { 13 | 14 | protected val logger: org.slf4j.Logger = Logger(getClass) 15 | 16 | // setup request/response logging 17 | private val logRequest: HttpRequest => HttpRequest = { r => logger.debug(HttpLogging.redactHttpRequest(r)); r } 18 | private val logResponse: HttpResponse => HttpResponse = { r => logger.debug(HttpLogging.redactHttpResponse(r)); r } 19 | 20 | private def requestWithAuthSupportingRedirect( 21 | http: HttpExt, req: HttpRequest, auth: AuthToken[Ours], redirectCount: Int = 0 22 | )(implicit ec: ExecutionContext, mat: Materializer): Future[HttpResponse] = { 23 | http.singleRequest( 24 | logRequest(req.addCredentials(GenericHttpCredentials("Token", auth.value, Map()))) 25 | ).flatMap { response => 26 | logResponse(response) 27 | response.status match { 28 | case StatusCodes.PermanentRedirect | StatusCodes.TemporaryRedirect if redirectCount < 10 => 29 | response.header[Location].map { newLoc => 30 | logger.warn("Following redirect to {}", newLoc.uri) 31 | response.discardEntityBytes() 32 | requestWithAuthSupportingRedirect(http, req.withUri(newLoc.uri), auth, redirectCount + 1) 33 | }.getOrElse(Future.successful(response)) 34 | case _ => Future.successful(response) 35 | } 36 | } 37 | } 38 | 39 | 40 | 41 | protected def requestWithAuth(http: HttpExt, req: HttpRequest, auth: AuthToken[Ours]) 42 | (implicit ec: ExecutionContext, mat: Materializer): Future[HttpResponse] = 43 | requestWithAuthSupportingRedirect(http, req, auth) 44 | } 45 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/CreateOrUpdateResult.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.model.{StatusCode, StatusCodes} 4 | 5 | sealed trait CreateOrUpdateResult { def httpStatusCode: StatusCode } 6 | object CreateOrUpdateResult { 7 | case object Created extends CreateOrUpdateResult { 8 | override def httpStatusCode: StatusCode = StatusCodes.Created 9 | } 10 | case object Updated extends CreateOrUpdateResult { 11 | override def httpStatusCode: StatusCode = StatusCodes.OK 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/DateDeserializers.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import akka.http.scaladsl.unmarshalling.Unmarshaller 6 | import com.thenewmotion.ocpi.ZonedDateTimeParser 7 | 8 | trait DateDeserializers { 9 | implicit val String2OcpiDate = Unmarshaller.strict[String, ZonedDateTime] { 10 | value => ZonedDateTimeParser.parse(value) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/EitherUnmarshalling.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.model.HttpEntity 5 | import _root_.akka.http.scaladsl.unmarshalling.Unmarshaller.EitherUnmarshallingException 6 | import _root_.akka.http.scaladsl.unmarshalling._ 7 | 8 | import scala.concurrent.Future 9 | import scala.concurrent.duration._ 10 | import scala.reflect.ClassTag 11 | 12 | trait EitherUnmarshalling { 13 | 14 | implicit def eitherUnmarshaller[L, R]( 15 | implicit ua: FromEntityUnmarshaller[L], rightTag: ClassTag[R], 16 | ub: FromEntityUnmarshaller[R], leftTag: ClassTag[L]): FromEntityUnmarshaller[Either[L, R]] = 17 | 18 | Unmarshaller.withMaterializer[HttpEntity, Either[L, R]] { implicit ex => implicit mat => value => 19 | import akka.http.scaladsl.util.FastFuture._ 20 | 21 | @inline def right(e: HttpEntity) = ub(e).fast.map(Right(_)) 22 | 23 | @inline def fallbackLeft(e: HttpEntity): PartialFunction[Throwable, Future[Either[L, R]]] = { case rightFirstEx => 24 | val left = ua(e).fast.map(Left(_)) 25 | 26 | left.transform( 27 | s = x => x, 28 | f = leftSecondEx => EitherUnmarshallingException( 29 | rightClass = rightTag.runtimeClass, right = rightFirstEx, 30 | leftClass = leftTag.runtimeClass, left = leftSecondEx) 31 | ) 32 | } 33 | 34 | for { 35 | e <- value.httpEntity.toStrict(EitherUnmarshalling.Timeout) 36 | res <- right(e).recoverWith(fallbackLeft(e)) 37 | } yield res 38 | } 39 | 40 | } 41 | 42 | object EitherUnmarshalling { 43 | val Timeout: FiniteDuration = 20.seconds 44 | } -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/HktMarshallable.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.marshalling.{GenericMarshallers, ToRequestMarshaller, ToResponseMarshaller} 4 | import cats.effect.{ContextShift, Effect} 5 | import scala.concurrent.Future 6 | 7 | /** 8 | * Turns a higher-kinded type into a Marshaller if 9 | * there is a Marshaller for the type argument in implicit scope. 10 | * 11 | * The bridge between IO and akka-http. 12 | */ 13 | trait HktMarshallable[F[_]] { 14 | def responseMarshaller[A : ToResponseMarshaller]: ToResponseMarshaller[F[A]] 15 | def requestMarshaller[A : ToRequestMarshaller]: ToRequestMarshaller[F[A]] 16 | } 17 | 18 | object HktMarshallableInstances { 19 | 20 | implicit def futureMarshaller: HktMarshallable[Future] = new HktMarshallable[Future] { 21 | def responseMarshaller[A: ToResponseMarshaller]: ToResponseMarshaller[Future[A]] = implicitly 22 | def requestMarshaller[A : ToRequestMarshaller]: ToRequestMarshaller[Future[A]] = implicitly 23 | } 24 | 25 | implicit def effectMarshaller[F[_]: Effect](implicit s: ContextShift[F], eff: Effect[F]): HktMarshallable[F] = new HktMarshallable[F] { 26 | def responseMarshaller[A](implicit m: ToResponseMarshaller[A]): ToResponseMarshaller[F[A]] = 27 | GenericMarshallers.futureMarshaller(m).compose(io => eff.toIO(eff.flatMap(s.shift)(_ => io)).unsafeToFuture()) 28 | 29 | def requestMarshaller[A](implicit m: ToRequestMarshaller[A]): ToRequestMarshaller[F[A]] = 30 | GenericMarshallers.futureMarshaller(m).compose(io => eff.toIO(eff.flatMap(s.shift)(_ => io)).unsafeToFuture()) 31 | } 32 | } 33 | 34 | 35 | object HktMarshallableSyntax { 36 | implicit def respMarshaller[F[_], A : ToResponseMarshaller](implicit M: HktMarshallable[F]): ToResponseMarshaller[F[A]] = 37 | M.responseMarshaller 38 | 39 | implicit def reqMarshaller[F[_], A : ToRequestMarshaller](implicit M: HktMarshallable[F]): ToRequestMarshaller[F[A]] = 40 | M.requestMarshaller 41 | } -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/HttpLogging.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse} 4 | import akka.http.scaladsl.model.headers.{Authorization, RawHeader} 5 | 6 | object HttpLogging { 7 | 8 | private val redactedHeaders: Map[String, String => String] = Map( 9 | Authorization.name -> (s => s.take("Token 123".length) + "**REDACTED**") 10 | ) 11 | 12 | def redactHttpRequest(httpReq: HttpRequest): String = 13 | httpReq.mapHeaders{ headers => headers.map { h => 14 | RawHeader(h.name(), redactedHeaders.getOrElse(h.name, identity[String](_))(h.value)) 15 | } 16 | }.toString 17 | 18 | def redactHttpResponse(httpRes: HttpResponse): String = 19 | httpRes.mapHeaders{ headers => headers.map { h => 20 | RawHeader(h.name(), redactedHeaders.getOrElse(h.name, identity[String](_))(h.value)) 21 | } 22 | }.toString 23 | } 24 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/OcpiClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.http.scaladsl.HttpExt 6 | import _root_.akka.http.scaladsl.model.{DateTime => _, _} 7 | import _root_.akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, Unmarshal} 8 | import _root_.akka.stream.Materializer 9 | import _root_.akka.stream.scaladsl.Sink 10 | import cats.effect.{Async, ContextShift} 11 | import cats.syntax.either._ 12 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 13 | import com.thenewmotion.ocpi.msgs.{AuthToken, ErrorResp, SuccessResp} 14 | import scala.concurrent.ExecutionContext 15 | import scala.reflect.ClassTag 16 | import scala.util.control.NonFatal 17 | 18 | //cf. chapter 3.1.3 from the OCPI 2.1 spec 19 | class ClientObjectUri (val value: Uri) extends AnyVal 20 | 21 | object ClientObjectUri { 22 | def apply( 23 | endpointUri: Uri, 24 | ourCountryCode: String, 25 | ourPartyId: String, 26 | ids: String* 27 | ): ClientObjectUri = { 28 | val epUriNormalised = 29 | if (endpointUri.path.endsWithSlash) endpointUri.path 30 | else endpointUri.path ++ Uri.Path./ 31 | 32 | val rest = ids.foldLeft(Uri.Path(ourCountryCode) / ourPartyId)(_ / _) 33 | new ClientObjectUri(endpointUri.withPath(epUriNormalised ++ rest)) 34 | } 35 | } 36 | 37 | /** 38 | * Internally used to carry failure information through Akka Streams 39 | */ 40 | private[common] case class OcpiClientException(errorResp: ErrorResp) 41 | extends Exception(s"OCPI client failure with code ${errorResp.statusCode.code}: ${errorResp.statusMessage.getOrElse("no status message")}") 42 | 43 | /** 44 | * Thrown to signal an error where no valid OCPI response was produced by the server 45 | */ 46 | case class FailedRequestException(request: HttpRequest, response: HttpResponse, cause: Throwable) 47 | extends Exception("Failed to get response to OCPI request", cause) 48 | 49 | abstract class OcpiClient[F[_]: Async](implicit http: HttpExt) 50 | extends AuthorizedRequests with EitherUnmarshalling with OcpiResponseUnmarshalling { 51 | 52 | protected def singleRequestRawT[T : ClassTag]( 53 | req: HttpRequest, 54 | auth: AuthToken[Ours] 55 | )( 56 | implicit ec: ExecutionContext, 57 | cs: ContextShift[F], 58 | mat: Materializer, 59 | errorU: ErrRespUnMar, 60 | sucU: FromEntityUnmarshaller[T] 61 | ): F[ErrorRespOr[T]] = { 62 | Async.fromFuture(Async[F].delay(requestWithAuth(http, req, auth).flatMap { response => 63 | Unmarshal(response).to[ErrorRespOr[T]].recover { 64 | case NonFatal(cause) => throw FailedRequestException(req, response, cause) 65 | } 66 | })) 67 | } 68 | 69 | def singleRequest[T]( 70 | req: HttpRequest, 71 | auth: AuthToken[Ours] 72 | )( 73 | implicit ec: ExecutionContext, 74 | cs: ContextShift[F], 75 | mat: Materializer, 76 | errorU: ErrRespUnMar, 77 | sucU: SuccessRespUnMar[T] 78 | ): F[ErrorRespOr[SuccessResp[T]]] = singleRequestRawT[SuccessResp[T]](req, auth) 79 | 80 | def traversePaginatedResource[T]( 81 | uri: Uri, 82 | auth: AuthToken[Ours], 83 | dateFrom: Option[ZonedDateTime] = None, 84 | dateTo: Option[ZonedDateTime] = None, 85 | limit: Int 86 | )( 87 | implicit ec: ExecutionContext, 88 | cs: ContextShift[F], 89 | mat: Materializer, 90 | successU: PagedRespUnMar[T], 91 | errorU: ErrRespUnMar 92 | ): F[ErrorRespOr[Iterable[T]]] = 93 | Async.fromFuture(Async[F].delay(PaginatedSource[T](http, uri, auth, dateFrom, dateTo, limit).runWith(Sink.seq[T]).map { 94 | _.asRight 95 | }.recover { 96 | case OcpiClientException(errorResp) => errorResp.asLeft 97 | })) 98 | } 99 | 100 | object OcpiClient { 101 | val DefaultPageLimit: Int = PaginatedSource.DefaultPageLimit 102 | } -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/OcpiDirectives.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.server.{Directive0, Directives, PathMatcher1} 5 | import msgs.GlobalPartyId 6 | import com.thenewmotion.ocpi.msgs.Versions.VersionNumber 7 | 8 | trait OcpiDirectives extends Directives { 9 | val GlobalPartyIdMatcher: PathMatcher1[GlobalPartyId] = (Segment / Segment).tmap { 10 | case (cc, p) => Tuple1(GlobalPartyId(cc, p)) 11 | } 12 | 13 | def authPathPrefixGlobalPartyIdEquality(apiUser: GlobalPartyId): Directive0 = 14 | pathPrefix(GlobalPartyIdMatcher).tflatMap { (urlUser: Tuple1[GlobalPartyId]) => 15 | authorize(apiUser == urlUser._1) 16 | } 17 | 18 | val AnyVersionMatcher: PathMatcher1[VersionNumber] = Segment.map(VersionNumber(_)) 19 | 20 | def VersionMatcher(validVersions: Set[VersionNumber]) = AnyVersionMatcher.flatMap { 21 | case x if validVersions.contains(x) => Some(x) 22 | case _ => None 23 | } 24 | } 25 | 26 | object OcpiDirectives extends OcpiDirectives 27 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/OcpiExceptionHandler.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.model.StatusCodes._ 5 | import _root_.akka.http.scaladsl.server.Directives._ 6 | import _root_.akka.http.scaladsl.server.ExceptionHandler 7 | import _root_.akka.http.scaladsl.server.directives.BasicDirectives 8 | import msgs.ErrorResp 9 | import msgs.OcpiStatusCode._ 10 | 11 | object OcpiExceptionHandler extends BasicDirectives { 12 | 13 | protected val logger = Logger(getClass) 14 | 15 | def Default( 16 | implicit m: ErrRespMar 17 | ) = ExceptionHandler { 18 | case exception => extractRequest { request => 19 | logger.error(s"An error occurred processing: ${HttpLogging.redactHttpRequest(request)}", exception) 20 | complete { 21 | ( OK, 22 | ErrorResp( 23 | GenericServerFailure, 24 | Some(exception.toString))) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/OcpiRejectionHandler.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.model.StatusCodes._ 5 | import _root_.akka.http.scaladsl.server.Directives._ 6 | import _root_.akka.http.scaladsl.server._ 7 | import _root_.akka.http.scaladsl.server.directives.BasicDirectives 8 | import msgs.ErrorResp 9 | import msgs.OcpiStatusCode._ 10 | 11 | object OcpiRejectionHandler extends BasicDirectives { 12 | 13 | def Default( 14 | implicit m: ErrRespMar 15 | ): RejectionHandler = 16 | RejectionHandler.newBuilder().handle { 17 | case MalformedRequestContentRejection(msg, _) => 18 | complete { 19 | (BadRequest, ErrorResp(GenericClientFailure, Some(msg))) 20 | } 21 | }.handle { 22 | case AuthenticationFailedRejection(AuthenticationFailedRejection.CredentialsMissing, _) => 23 | complete { 24 | ( Unauthorized, 25 | ErrorResp( 26 | MissingHeader, 27 | Some("Authorization Token not supplied"))) 28 | } 29 | }.handle { 30 | case AuthenticationFailedRejection(AuthenticationFailedRejection.CredentialsRejected, _) => 31 | complete { 32 | ( Unauthorized, 33 | ErrorResp( 34 | AuthenticationFailed, 35 | Some("Invalid Authorization Token"))) 36 | } 37 | }.handle { 38 | case AuthorizationFailedRejection => extractUri { uri => 39 | complete { 40 | Forbidden -> ErrorResp( 41 | GenericClientFailure, 42 | Some(s"The client is not authorized to access ${uri.toRelative}") 43 | ) 44 | } 45 | } 46 | }.handle { 47 | case MissingHeaderRejection(header) => complete { 48 | ( BadRequest, 49 | ErrorResp( 50 | MissingHeader, 51 | Some(s"Header not found: '$header'"))) 52 | } 53 | }.handleAll[Rejection] { rejections => 54 | complete { 55 | ( BadRequest, 56 | ErrorResp( 57 | GenericClientFailure, 58 | Some(rejections.mkString(", ")))) 59 | } 60 | }.result() 61 | } 62 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/OcpiResponseUnmarshalling.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.model.headers.{Link, LinkParams} 5 | import _root_.akka.http.scaladsl.model.{HttpResponse, Uri} 6 | import _root_.akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, FromResponseUnmarshaller, Unmarshaller} 7 | import msgs.{ErrorResp, SuccessResp} 8 | 9 | case class UnexpectedResponseException(response: HttpResponse) 10 | extends Exception(s"Unexpected HTTP status code ${response.status}") 11 | 12 | trait OcpiResponseUnmarshalling { 13 | type ErrorRespOr[T] = Either[ErrorResp, T] 14 | 15 | protected implicit def fromOcpiResponseUnmarshaller[D]( 16 | implicit disjUnMa: FromEntityUnmarshaller[ErrorRespOr[D]] 17 | ): FromResponseUnmarshaller[ErrorRespOr[D]] = 18 | Unmarshaller.withMaterializer[HttpResponse, ErrorRespOr[D]] { 19 | implicit ex => implicit mat => response: HttpResponse => 20 | if (response.status.isSuccess) 21 | disjUnMa(response.entity) 22 | else { 23 | response.discardEntityBytes() 24 | throw UnexpectedResponseException(response) 25 | } 26 | } 27 | 28 | type PagedResp[T] = SuccessResp[Iterable[T]] 29 | 30 | protected implicit def fromPagedOcpiResponseUnmarshaller[T]( 31 | implicit um: FromResponseUnmarshaller[ErrorRespOr[PagedResp[T]]] 32 | ): FromResponseUnmarshaller[ErrorRespOr[(PagedResp[T], Option[Uri])]] = 33 | 34 | Unmarshaller.withMaterializer[HttpResponse, ErrorRespOr[(PagedResp[T], Option[Uri])]] { 35 | implicit ex => implicit mat => response: HttpResponse => 36 | um(response).map { _.map { 37 | (x: PagedResp[T]) => 38 | (x, response 39 | .header[Link] 40 | .flatMap(_.values.find(_.params.contains(LinkParams.next)).map(_.uri))) 41 | }} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/PaginatedRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.http.scaladsl.model.Uri.Query 6 | import _root_.akka.http.scaladsl.model.headers.{Link, LinkParams, RawHeader} 7 | import _root_.akka.http.scaladsl.server.{Directive0, Directives} 8 | 9 | case class PaginatedResult[+T](result: Iterable[T], total: Int) 10 | 11 | object PaginatedResult { 12 | val empty = PaginatedResult(Seq.empty, 0) 13 | } 14 | 15 | case class Pager(offset: Int, limit: Int) { 16 | def nextOffset = offset + limit 17 | } 18 | 19 | trait PaginatedRoute extends Directives with DateDeserializers { 20 | 21 | private val DefaultOffset = 0 22 | 23 | def DefaultLimit: Int 24 | def MaxLimit: Int 25 | 26 | private val offset = parameter(Symbol("offset").as[Int] ? DefaultOffset) 27 | 28 | private val limit = parameter(Symbol("limit").as[Int] ? DefaultLimit).tmap { 29 | case Tuple1(l) => Math.min(l, MaxLimit) 30 | } 31 | 32 | private val dateFrom = parameter("date_from".as[ZonedDateTime].?) 33 | 34 | private val dateTo = parameter("date_to".as[ZonedDateTime].?) 35 | 36 | val paged = (offset & limit).as(Pager) & dateFrom & dateTo 37 | 38 | def linkHeaderScheme: Option[String] = None 39 | 40 | def respondWithPaginationHeaders(pager: Pager, pagRes: PaginatedResult[_]): Directive0 = 41 | extract(identity) flatMap { ctx => 42 | val baseHeaders = List( 43 | RawHeader("X-Limit", pager.limit.toString), 44 | RawHeader("X-Total-Count", pagRes.total.toString) 45 | ) 46 | 47 | respondWithHeaders { 48 | if (pager.nextOffset >= pagRes.total) baseHeaders 49 | else { 50 | val linkParams = Query(ctx.request.uri.query().toMap ++ 51 | Map( 52 | "offset" -> pager.nextOffset.toString, 53 | "limit" -> pager.limit.toString 54 | )) 55 | 56 | val uri = linkHeaderScheme.foldLeft(ctx.request.uri.withQuery(linkParams)) { 57 | case (u, s) => u.withScheme(s) 58 | } 59 | 60 | baseHeaders :+ 61 | Link(uri, LinkParams.next) 62 | } 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/PaginatedSource.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.NotUsed 6 | import _root_.akka.http.scaladsl.HttpExt 7 | import _root_.akka.http.scaladsl.client.RequestBuilding.Get 8 | import _root_.akka.http.scaladsl.model.Uri.Query 9 | import _root_.akka.http.scaladsl.model.{HttpRequest, Uri} 10 | import _root_.akka.http.scaladsl.unmarshalling.Unmarshal 11 | import _root_.akka.stream.Materializer 12 | import _root_.akka.stream.scaladsl.Source 13 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 14 | import msgs.{AuthToken, ErrorResp} 15 | import com.thenewmotion.ocpi.ZonedDateTimeParser._ 16 | import cats.syntax.either._ 17 | import cats.instances.future._ 18 | import scala.concurrent.{ExecutionContext, Future} 19 | import scala.util.control.NonFatal 20 | 21 | case class PaginationException(uri: Uri, cause: Throwable) 22 | extends RuntimeException(s"An error occurred when calling ${uri.toString}", cause) 23 | 24 | object PaginatedSource extends AuthorizedRequests with EitherUnmarshalling with OcpiResponseUnmarshalling { 25 | 26 | val DefaultPageLimit = 100 27 | 28 | private def singleRequestWithNextLink[T]( 29 | http: HttpExt, 30 | req: HttpRequest, 31 | auth: AuthToken[Ours] 32 | )( 33 | implicit ec: ExecutionContext, 34 | mat: Materializer, 35 | successU: PagedRespUnMar[T], 36 | errorU: ErrRespUnMar 37 | ): Future[Either[ErrorResp, (PagedResp[T], Option[Uri])]] = 38 | (for { 39 | response <- result(requestWithAuth(http, req, auth).map(_.asRight)) 40 | success <- result(Unmarshal(response).to[ErrorRespOr[(PagedResp[T], Option[Uri])]]) 41 | } yield success).value.recoverWith { 42 | case NonFatal(ex) => Future.failed(PaginationException(req.uri, ex)) 43 | } 44 | 45 | def apply[T]( 46 | http: HttpExt, 47 | uri: Uri, 48 | auth: AuthToken[Ours], 49 | dateFrom: Option[ZonedDateTime] = None, 50 | dateTo: Option[ZonedDateTime] = None, 51 | pageLimit: Int = OcpiClient.DefaultPageLimit 52 | )(implicit ec: ExecutionContext, mat: Materializer, 53 | successU: PagedRespUnMar[T], errorU: ErrRespUnMar): Source[T, NotUsed] = { 54 | val query = Query(Map( 55 | "offset" -> "0", 56 | "limit" -> pageLimit.toString) ++ 57 | dateFrom.map("date_from" -> format(_)) ++ 58 | dateTo.map("date_to" -> format(_)) 59 | ) 60 | 61 | Source.unfoldAsync[Option[Uri], Iterable[T]](Some(uri withQuery query)) { 62 | case Some(x) => 63 | singleRequestWithNextLink[T](http, Get(x), auth).map { 64 | case Left(err) => throw OcpiClientException(err) 65 | case Right((success, u)) => Some((u, success.data)) 66 | } 67 | case None => Future.successful(None) 68 | }.mapConcat(_.toList) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/TokenAuthenticator.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package common 3 | 4 | import _root_.akka.http.scaladsl.model.headers.{GenericHttpCredentials, HttpChallenge, HttpCredentials} 5 | import _root_.akka.http.scaladsl.server.directives.SecurityDirectives.AuthenticationResult 6 | import cats.effect.Effect 7 | import com.thenewmotion.ocpi.msgs.Ownership.Theirs 8 | import com.thenewmotion.ocpi.msgs.{AuthToken, GlobalPartyId} 9 | import scala.concurrent.{ExecutionContext, Future} 10 | 11 | class TokenAuthenticator[F[_]: Effect]( 12 | toApiUser: AuthToken[Theirs] => F[Option[GlobalPartyId]] 13 | )( 14 | implicit executionContext: ExecutionContext 15 | ) extends (Option[HttpCredentials] => Future[AuthenticationResult[GlobalPartyId]]) { 16 | 17 | lazy val challenge: HttpChallenge = HttpChallenge(scheme = "Token", realm = "ocpi") 18 | 19 | override def apply(credentials: Option[HttpCredentials]): Future[AuthenticationResult[GlobalPartyId]] = { 20 | credentials 21 | .flatMap { 22 | case GenericHttpCredentials("Token", token, params) => 23 | if(token.nonEmpty) Some(token) else params.headOption.map(_._2) 24 | case _ => 25 | None 26 | } match { 27 | case None => Future.successful(Left(challenge)) 28 | case Some(x) => Effect[F].toIO(toApiUser(AuthToken[Theirs](x))).unsafeToFuture().map { 29 | case Some(x2) => Right(x2) 30 | case None => Left(challenge) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/common/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller 4 | import _root_.akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 5 | import cats.data.EitherT 6 | import common.PaginatedSource.PagedResp 7 | import msgs.{ErrorResp, SuccessResp} 8 | 9 | import scala.concurrent.Future 10 | 11 | package object common { 12 | type ErrRespUnMar = FromEntityUnmarshaller[ErrorResp] 13 | type ErrRespMar = ToEntityMarshaller[ErrorResp] 14 | type SuccessRespUnMar[T] = FromEntityUnmarshaller[SuccessResp[T]] 15 | type SuccessRespMar[T] = ToEntityMarshaller[SuccessResp[T]] 16 | type PagedRespUnMar[T] = FromEntityUnmarshaller[PagedResp[T]] 17 | 18 | type Result[E, T] = EitherT[Future, E, T] 19 | 20 | def result[L, T](future: Future[Either[L, T]]): Result[L, T] = EitherT(future) 21 | } 22 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/msgs/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import _root_.akka.http.scaladsl.model.Uri 4 | import scala.language.implicitConversions 5 | 6 | package object msgs { 7 | implicit def urlToUri(url: Url): Uri = Uri(url.value) 8 | } 9 | -------------------------------------------------------------------------------- /endpoints-common/src/main/scala/com/thenewmotion/ocpi/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion 2 | 3 | import akka.http.scaladsl.server.Route 4 | import cats.Functor 5 | import com.thenewmotion.ocpi.msgs.GlobalPartyId 6 | import com.thenewmotion.ocpi.msgs.Versions.VersionNumber 7 | import org.slf4j.{Logger, LoggerFactory} 8 | 9 | package object ocpi { 10 | def Logger(cls: Class[_]): Logger = LoggerFactory.getLogger(cls) 11 | 12 | implicit class RichFEither[F[_]: Functor, L, R](value: F[Either[L, R]]) { 13 | import cats.syntax.functor._ 14 | def mapRight[T](f: R => T): F[Either[L, T]] = 15 | value.map(_.map(f)) 16 | } 17 | 18 | type Version = VersionNumber 19 | 20 | type GuardedRoute = (Version, GlobalPartyId) => Route 21 | } 22 | -------------------------------------------------------------------------------- /endpoints-common/src/test/scala/com/thenewmotion/ocpi/common/ClientObjectUriSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.model.Uri 4 | import org.specs2.mutable.Specification 5 | 6 | class ClientObjectUriSpec extends Specification { 7 | 8 | "Client object URI" >> { 9 | "can contain a number of extra fields" >> { 10 | val countryCode = "NL" 11 | val partyId = "TNM" 12 | val endpoint = Uri("http://localhost/locations") 13 | 14 | ClientObjectUri(endpoint, countryCode, partyId, "12345").value === 15 | Uri("http://localhost/locations/NL/TNM/12345") 16 | 17 | ClientObjectUri(endpoint, countryCode, partyId, "111", "222", "333").value === 18 | Uri("http://localhost/locations/NL/TNM/111/222/333") 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /endpoints-common/src/test/scala/com/thenewmotion/ocpi/common/HktMarshallableFromECInstances.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.marshalling.{GenericMarshallers, ToRequestMarshaller, ToResponseMarshaller} 4 | import cats.Id 5 | import cats.effect.{ContextShift, IO} 6 | import scala.concurrent.ExecutionContext 7 | 8 | /** 9 | * Convenience marshallers to help keep test code clean 10 | */ 11 | object HktMarshallableFromECInstances { 12 | 13 | implicit def ioCsFromEcMarshaller(implicit ec: ExecutionContext): HktMarshallable[IO] = new HktMarshallable[IO] { 14 | def responseMarshaller[A](implicit m: ToResponseMarshaller[A]): ToResponseMarshaller[IO[A]] = { 15 | implicit val s: ContextShift[IO] = IO.contextShift(ec) 16 | GenericMarshallers.futureMarshaller(m).compose(io => (s.shift *> io).unsafeToFuture()) 17 | } 18 | 19 | def requestMarshaller[A](implicit m: ToRequestMarshaller[A]): ToRequestMarshaller[IO[A]] = { 20 | implicit val s: ContextShift[IO] = IO.contextShift(ec) 21 | GenericMarshallers.futureMarshaller(m).compose(io => (s.shift *> io).unsafeToFuture()) 22 | } 23 | } 24 | 25 | implicit def idMarshaller: HktMarshallable[Id] = new HktMarshallable[Id] { 26 | def responseMarshaller[A: ToResponseMarshaller]: ToResponseMarshaller[A] = implicitly 27 | def requestMarshaller[A : ToRequestMarshaller]: ToRequestMarshaller[A] = implicitly 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /endpoints-common/src/test/scala/com/thenewmotion/ocpi/common/IOMatchersExt.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import org.specs2.matcher.{Expectable, IOMatchers, MatchFailure, MatchResult, Matcher, ValueChecksBase} 4 | 5 | trait IOMatchersExt extends IOMatchers with ValueChecksBase { 6 | 7 | def returnValueLike[T](pattern: PartialFunction[T, MatchResult[_]]): IOMatcher[T] = { 8 | val m: Matcher[T] = new Matcher[T] { 9 | override def apply[S <: T](a: Expectable[S]) = { 10 | val r = if (pattern.isDefinedAt(a.value)) pattern.apply(a.value) else MatchFailure("", "", a) 11 | result(r.isSuccess, 12 | a.description + " is correct: " + r.message, 13 | a.description + " is incorrect: " + r.message, 14 | a) 15 | } 16 | } 17 | attemptRun(m, None) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /endpoints-common/src/test/scala/com/thenewmotion/ocpi/common/OcpiRejectionHandlerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.common 2 | 3 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 4 | import akka.http.scaladsl.model.StatusCodes.{BadRequest, Forbidden, Unauthorized} 5 | import akka.http.scaladsl.model.headers.HttpChallenge 6 | import akka.http.scaladsl.server._ 7 | import akka.http.scaladsl.testkit.Specs2RouteTest 8 | import com.thenewmotion.ocpi.msgs 9 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 10 | import com.thenewmotion.ocpi.msgs._ 11 | import org.specs2.mock.Mockito 12 | import org.specs2.mutable.Specification 13 | import org.specs2.specification.Scope 14 | import msgs.sprayjson.v2_1.protocol._ 15 | 16 | class OcpiRejectionHandlerSpec extends Specification with Specs2RouteTest with Mockito { 17 | 18 | "OcpiRejectionHandler" should { 19 | "handle Malformed Content" in new TestScope { 20 | Get() ~> route(MalformedRequestContentRejection("Something is wrong", new RuntimeException("Ooopsie"))) ~> check { 21 | val res = entityAs[ErrorResp] 22 | res.statusCode mustEqual GenericClientFailure 23 | res.statusMessage must beSome("Something is wrong") 24 | status mustEqual BadRequest 25 | } 26 | } 27 | 28 | "handle missing credentials header" in new TestScope { 29 | Get() ~> route( 30 | AuthenticationFailedRejection(AuthenticationFailedRejection.CredentialsMissing, HttpChallenge("blah", "blah")) 31 | ) ~> check { 32 | val res = entityAs[ErrorResp] 33 | res.statusCode mustEqual MissingHeader 34 | res.statusMessage must beSome("Authorization Token not supplied") 35 | status mustEqual Unauthorized 36 | } 37 | } 38 | 39 | "handle wrong credentials" in new TestScope { 40 | Get() ~> route( 41 | AuthenticationFailedRejection(AuthenticationFailedRejection.CredentialsRejected, HttpChallenge("blah", "blah")) 42 | ) ~> check { 43 | val res = entityAs[ErrorResp] 44 | res.statusCode mustEqual AuthenticationFailed 45 | res.statusMessage must beSome("Invalid Authorization Token") 46 | status mustEqual Unauthorized 47 | } 48 | } 49 | 50 | "handle not authorized" in new TestScope { 51 | Get() ~> route( 52 | AuthorizationFailedRejection 53 | ) ~> check { 54 | val res = entityAs[ErrorResp] 55 | res.statusCode mustEqual GenericClientFailure 56 | res.statusMessage must beSome("The client is not authorized to access /") 57 | status mustEqual Forbidden 58 | } 59 | } 60 | 61 | "handle missing header" in new TestScope { 62 | Get() ~> route( 63 | MissingHeaderRejection("monkeys") 64 | ) ~> check { 65 | val res = entityAs[ErrorResp] 66 | res.statusCode mustEqual MissingHeader 67 | res.statusMessage must beSome("Header not found: 'monkeys'") 68 | status mustEqual BadRequest 69 | } 70 | } 71 | 72 | "handle other rejections" in new TestScope { 73 | Get() ~> route( 74 | MissingCookieRejection("xyz"), UnsupportedWebSocketSubprotocolRejection("abc") 75 | ) ~> check { 76 | val res = entityAs[ErrorResp] 77 | res.statusCode mustEqual GenericClientFailure 78 | res.statusMessage must beSome("MissingCookieRejection(xyz), UnsupportedWebSocketSubprotocolRejection(abc)") 79 | status mustEqual BadRequest 80 | } 81 | } 82 | } 83 | 84 | trait TestScope extends Scope with OcpiDirectives with SprayJsonSupport { 85 | def route(rejections: Rejection*): Route = 86 | handleRejections(OcpiRejectionHandler.Default) { 87 | reject(rejections: _*) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /endpoints-cpo-locations/src/main/scala/com/thenewmotion/ocpi/locations/CpoLocationsService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package locations 3 | 4 | import java.time.ZonedDateTime 5 | 6 | import common.Pager 7 | import common.PaginatedResult 8 | import msgs.v2_1.Locations._ 9 | 10 | 11 | trait CpoLocationsService[F[_]] { 12 | def locations( 13 | pager: Pager, 14 | dateFrom: Option[ZonedDateTime] = None, 15 | dateTo: Option[ZonedDateTime] = None 16 | ): F[Either[LocationsError, PaginatedResult[Location]]] 17 | 18 | def location(locId: LocationId): F[Either[LocationsError, Location]] 19 | 20 | def evse(locId: LocationId, evseUid: EvseUid): F[Either[LocationsError, Evse]] 21 | 22 | def connector(locId: LocationId, evseUid: EvseUid, connectorId: ConnectorId): F[Either[LocationsError, Connector]] 23 | } 24 | -------------------------------------------------------------------------------- /endpoints-cpo-locations/src/main/scala/com/thenewmotion/ocpi/locations/LocationsError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package locations 3 | 4 | sealed trait LocationsError {def reason: Option[String]} 5 | 6 | object LocationsError { 7 | case class LocationNotFound(reason: Option[String] = None) extends LocationsError 8 | case class LocationUpdateFailed(reason: Option[String] = None) extends LocationsError 9 | case class EvseNotFound(reason: Option[String] = None) extends LocationsError 10 | case class ConnectorNotFound(reason: Option[String] = None) extends LocationsError 11 | } 12 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/CpoTokensRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import _root_.akka.http.scaladsl.marshalling.ToResponseMarshaller 5 | import _root_.akka.http.scaladsl.model.StatusCode 6 | import _root_.akka.http.scaladsl.model.StatusCodes._ 7 | import _root_.akka.http.scaladsl.server.{PathMatcher1, Route} 8 | import _root_.akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 9 | import cats.Applicative 10 | import com.thenewmotion.ocpi.common._ 11 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode.{GenericClientFailure, GenericSuccess} 12 | import com.thenewmotion.ocpi.msgs._ 13 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens._ 14 | import com.thenewmotion.ocpi.tokens.TokenError._ 15 | 16 | object CpoTokensRoute { 17 | def apply[F[_]: Applicative: HktMarshallable]( 18 | service: CpoTokensService[F] 19 | )( 20 | implicit successTokenM: SuccessRespMar[Token], 21 | successUnitM: SuccessRespMar[Unit], 22 | errorM: ErrRespMar, 23 | tokenU: FromEntityUnmarshaller[Token], 24 | tokenPU: FromEntityUnmarshaller[TokenPatch] 25 | ): CpoTokensRoute[F] = new CpoTokensRoute(service) 26 | } 27 | 28 | class CpoTokensRoute[F[_]: Applicative: HktMarshallable] private[ocpi]( 29 | service: CpoTokensService[F] 30 | )( 31 | implicit successTokenM: SuccessRespMar[Token], 32 | successUnitM: SuccessRespMar[Unit], 33 | errorM: ErrRespMar, 34 | tokenU: FromEntityUnmarshaller[Token], 35 | tokenPU: FromEntityUnmarshaller[TokenPatch] 36 | ) extends EitherUnmarshalling with OcpiDirectives { 37 | 38 | implicit def tokenErrorResp( 39 | implicit em: ToResponseMarshaller[(StatusCode, ErrorResp)] 40 | ): ToResponseMarshaller[TokenError] = { 41 | em.compose[TokenError] { tokenError => 42 | val statusCode = tokenError match { 43 | case _: TokenNotFound => NotFound 44 | case _: TokenCreationFailed | _: TokenUpdateFailed => OK 45 | case _ => InternalServerError 46 | } 47 | statusCode -> ErrorResp(GenericClientFailure, tokenError.reason) 48 | } 49 | } 50 | 51 | private val TokenUidSegment: PathMatcher1[TokenUid] = Segment.map(TokenUid(_)) 52 | 53 | import HktMarshallableSyntax._ 54 | 55 | def apply( 56 | apiUser: GlobalPartyId 57 | ): Route = 58 | handleRejections(OcpiRejectionHandler.Default) { 59 | (authPathPrefixGlobalPartyIdEquality(apiUser) & pathPrefix(TokenUidSegment)) { tokenUid => 60 | pathEndOrSingleSlash { 61 | put { 62 | entity(as[Token]) { token => 63 | complete { 64 | service.createOrUpdateToken(apiUser, tokenUid, token).mapRight { x => 65 | (x.httpStatusCode, SuccessResp(GenericSuccess)) 66 | } 67 | } 68 | } 69 | } ~ 70 | patch { 71 | entity(as[TokenPatch]) { patch => 72 | complete { 73 | service.updateToken(apiUser, tokenUid, patch).mapRight { _ => 74 | SuccessResp(GenericSuccess) 75 | } 76 | } 77 | } 78 | } ~ 79 | get { 80 | complete { 81 | service.token(apiUser, tokenUid).mapRight { token => 82 | SuccessResp(GenericSuccess, data = token) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/CpoTokensService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import cats.Applicative 5 | import com.thenewmotion.ocpi.common.CreateOrUpdateResult 6 | import msgs.GlobalPartyId 7 | import msgs.v2_1.Tokens._ 8 | import cats.syntax.either._ 9 | import cats.syntax.option._ 10 | import com.thenewmotion.ocpi.tokens.TokenError.IncorrectTokenId 11 | 12 | 13 | /** 14 | * All methods are to be implemented with idempotency semantics. 15 | */ 16 | trait CpoTokensService[F[_]] { 17 | 18 | /** 19 | * @return retrieve the token if it exists, otherwise returns TokenNotFound Error 20 | */ 21 | def token(globalPartyId: GlobalPartyId, tokenUid: TokenUid): F[Either[TokenError, Token]] 22 | 23 | protected[tokens] def createOrUpdateToken( 24 | apiUser: GlobalPartyId, 25 | tokenUid: TokenUid, 26 | token: Token 27 | )( 28 | implicit A: Applicative[F] 29 | ): F[Either[TokenError, CreateOrUpdateResult]] = { 30 | if (token.uid == tokenUid) { 31 | createOrUpdateToken(apiUser, token) 32 | } else 33 | Applicative[F].pure( 34 | IncorrectTokenId(s"Token id from Url is $tokenUid, but id in JSON body is ${token.uid}".some).asLeft 35 | ) 36 | } 37 | 38 | /** 39 | * returns TokenCreationFailed if an error occurred. 40 | */ 41 | def createOrUpdateToken( 42 | globalPartyId: GlobalPartyId, 43 | token: Token 44 | ): F[Either[TokenError, CreateOrUpdateResult]] 45 | 46 | /** 47 | * returns TokenUpdateFailed if an error occurred. 48 | */ 49 | def updateToken( 50 | globalPartyId: GlobalPartyId, 51 | tokenUid: TokenUid, 52 | tokenPatch: TokenPatch 53 | ): F[Either[TokenError, Unit]] 54 | } 55 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/MspTokensClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import _root_.akka.http.scaladsl._ 5 | import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller 6 | import _root_.akka.http.scaladsl.model.Uri 7 | import _root_.akka.stream.Materializer 8 | import akka.http.scaladsl.client.RequestBuilding._ 9 | import cats.effect.{Async, ContextShift} 10 | import cats.syntax.either._ 11 | import cats.syntax.functor._ 12 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, SuccessRespUnMar} 13 | import com.thenewmotion.ocpi.msgs.AuthToken 14 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 15 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.{AuthorizationInfo, LocationReferences, TokenUid} 16 | import scala.concurrent._ 17 | 18 | class MspTokensClient[F[_]: Async]( 19 | implicit http: HttpExt, 20 | successU: SuccessRespUnMar[AuthorizationInfo], 21 | errorU: ErrRespUnMar, 22 | locRefM: ToEntityMarshaller[LocationReferences] 23 | ) extends OcpiClient[F] { 24 | 25 | def authorize( 26 | endpointUri: Uri, 27 | authToken: AuthToken[Ours], 28 | tokenUid: TokenUid, 29 | locationReferences: Option[LocationReferences] 30 | )( 31 | implicit ec: ExecutionContext, 32 | cs: ContextShift[F], 33 | mat: Materializer 34 | ): F[ErrorRespOr[AuthorizationInfo]] = { 35 | val oldPath = endpointUri.path 36 | val authorizeUri = endpointUri.withPath { 37 | (if (oldPath.endsWithSlash) oldPath + tokenUid.value else oldPath / tokenUid.value) / "authorize" 38 | } 39 | singleRequest[AuthorizationInfo](Post(authorizeUri, locationReferences), authToken) map { 40 | _.bimap({ err => 41 | logger.error(s"Error getting real-time authorization from $authorizeUri: $err") 42 | err 43 | }, _.data) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/TokenError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | sealed trait TokenError { 5 | def reason: Option[String] 6 | } 7 | object TokenError { 8 | case class TokenNotFound(reason: Option[String] = None) extends TokenError 9 | case class TokenCreationFailed(reason: Option[String] = None) extends TokenError 10 | case class TokenUpdateFailed(reason: Option[String] = None) extends TokenError 11 | case class IncorrectTokenId(reason: Option[String] = None) extends TokenError 12 | } 13 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/TokensClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.http.scaladsl.HttpExt 6 | import _root_.akka.http.scaladsl.model.Uri 7 | import _root_.akka.stream.Materializer 8 | import cats.effect.{Async, ContextShift} 9 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, PagedRespUnMar} 10 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 11 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.Token 12 | import com.thenewmotion.ocpi.msgs.{AuthToken, ErrorResp} 13 | import scala.concurrent.ExecutionContext 14 | 15 | class TokensClient[F[_]: Async]( 16 | implicit http: HttpExt, 17 | errorU: ErrRespUnMar, 18 | sucU: PagedRespUnMar[Token] 19 | ) extends OcpiClient[F] { 20 | 21 | def getTokens( 22 | uri: Uri, 23 | auth: AuthToken[Ours], 24 | dateFrom: Option[ZonedDateTime] = None, 25 | dateTo: Option[ZonedDateTime] = None, 26 | pageLimit: Int = OcpiClient.DefaultPageLimit 27 | )( 28 | implicit ec: ExecutionContext, 29 | cs: ContextShift[F], 30 | mat: Materializer 31 | ): F[Either[ErrorResp, Iterable[Token]]] = 32 | traversePaginatedResource[Token](uri, auth, dateFrom, dateTo, pageLimit) 33 | } 34 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/test/scala/com/thenewmotion/ocpi/tokens/CpoTokensRouteSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import java.time.{Instant, ZoneOffset, ZonedDateTime} 5 | import akka.http.scaladsl.testkit.Specs2RouteTest 6 | import cats.syntax.either._ 7 | import cats.{Applicative, Id} 8 | import com.thenewmotion.ocpi.common.CreateOrUpdateResult 9 | import com.thenewmotion.ocpi.msgs.{ErrorResp, GlobalPartyId, Language} 10 | import org.specs2.mock.Mockito 11 | import org.specs2.mutable.Specification 12 | import org.specs2.specification.Scope 13 | 14 | class CpoTokensRouteSpec extends Specification with Specs2RouteTest with Mockito { 15 | 16 | import TokenError.TokenNotFound 17 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ 18 | import akka.http.scaladsl.model.StatusCodes 19 | import msgs.sprayjson.v2_1.protocol._ 20 | import msgs.v2_1.Tokens._ 21 | 22 | "tokens endpoint" should { 23 | "reject unauthorized access" in new TokensTestScope { 24 | val unAuthorizedUser = GlobalPartyId("NL", "SBM") 25 | 26 | Put(s"$tokenPath/$tokenUid") ~> akka.http.scaladsl.server.Route.seal( 27 | cpoTokensRoute(unAuthorizedUser)) ~> check { 28 | responseAs[ErrorResp] 29 | status mustEqual StatusCodes.Forbidden 30 | } 31 | } 32 | 33 | "accept a new token object" in new TokensTestScope { 34 | val token = Token( 35 | tokenUid, 36 | TokenType.Rfid, 37 | authId = AuthId("FA54320"), 38 | visualNumber = Some("DF000-2001-8999"), 39 | issuer = "TheNewMotion", 40 | valid = true, 41 | WhitelistType.Allowed, 42 | language = Some(Language("nl")), 43 | lastUpdated = ZonedDateTime.now 44 | ) 45 | 46 | cpoTokensService.createOrUpdateToken( 47 | ===(apiUser), 48 | ===(tokenUid), 49 | any[Token] 50 | )(any[Applicative[Id]]) returns CreateOrUpdateResult.Created.asRight 51 | 52 | def beMostlyEqualTo = (be_==(_: Token)) ^^^ ((_: Token).copy(lastUpdated = 53 | ZonedDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC))) 54 | 55 | Put(s"$tokenPath/$tokenUid", token) ~> 56 | cpoTokensRoute(apiUser) ~> check { 57 | there was one(cpoTokensService).createOrUpdateToken( 58 | ===(apiUser), 59 | ===(tokenUid), 60 | beMostlyEqualTo(token) 61 | )(any[Applicative[Id]]) 62 | there were noCallsTo(cpoTokensService) 63 | } 64 | } 65 | 66 | "accept patches to a token object" in new TokensTestScope { 67 | val whitelistPatch = Some(WhitelistType.Always) 68 | val tokenPatch = TokenPatch( 69 | whitelist = whitelistPatch 70 | ) 71 | 72 | cpoTokensService.updateToken( 73 | apiUser, 74 | tokenUid, 75 | tokenPatch 76 | ) returns ().asRight 77 | 78 | Patch(s"$tokenPath/$tokenUid", tokenPatch) ~> 79 | cpoTokensRoute(apiUser) ~> check { 80 | there was one(cpoTokensService).updateToken( 81 | apiUser, 82 | tokenUid, 83 | tokenPatch 84 | ) 85 | there were noCallsTo(cpoTokensService) 86 | } 87 | } 88 | 89 | "retrieve a token object" in new TokensTestScope { 90 | cpoTokensService.token( 91 | apiUser, 92 | tokenUid 93 | ) returns TokenNotFound().asLeft 94 | 95 | Get(s"$tokenPath/$tokenUid") ~> 96 | cpoTokensRoute(apiUser) ~> check { 97 | there was one(cpoTokensService).token( 98 | apiUser, 99 | tokenUid 100 | ) 101 | there were noCallsTo(cpoTokensService) 102 | } 103 | } 104 | } 105 | 106 | trait TokensTestScope extends Scope { 107 | val tokenUid = TokenUid("012345678") 108 | val countryCodeString = "NL" 109 | val operatorIdString = "TNM" 110 | val apiUser = GlobalPartyId(countryCodeString, operatorIdString) 111 | val tokenPath = s"/$countryCodeString/$operatorIdString" 112 | val cpoTokensService = mock[CpoTokensService[Id]] 113 | import com.thenewmotion.ocpi.common.HktMarshallableFromECInstances.idMarshaller 114 | val cpoTokensRoute = CpoTokensRoute(cpoTokensService) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /endpoints-cpo-tokens/src/test/scala/com/thenewmotion/ocpi/tokens/MspTokensClientSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.tokens 2 | 3 | import java.net.UnknownHostException 4 | import akka.actor.ActorSystem 5 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ 6 | import akka.http.scaladsl.model.ContentTypes._ 7 | import akka.http.scaladsl.model.StatusCodes.{ClientError => _, _} 8 | import akka.http.scaladsl.model.{HttpEntity, HttpRequest, HttpResponse, Uri} 9 | import akka.http.scaladsl.{Http, HttpExt} 10 | import akka.stream.Materializer 11 | import akka.util.Timeout 12 | import cats.effect.IO 13 | import com.thenewmotion.ocpi.common.IOMatchersExt 14 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 15 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 16 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.{EvseUid, LocationId} 17 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.{Allowed, AuthorizationInfo, LocationReferences, TokenUid} 18 | import com.thenewmotion.ocpi.msgs.{AuthToken, ErrorResp} 19 | import org.specs2.concurrent.ExecutionEnv 20 | import org.specs2.mutable.Specification 21 | import org.specs2.specification.Scope 22 | import scala.concurrent.duration._ 23 | import scala.concurrent.{ExecutionContext, Future} 24 | 25 | class MspTokensClientSpec(implicit ee: ExecutionEnv) extends Specification with IOMatchersExt { 26 | "MSpTokensClient" should { 27 | 28 | "request authorization for a token and get it" in new TestScope { 29 | 30 | client.authorize(theirTokensEndpointUri, AuthToken[Ours]("auth"), tokenId, 31 | locationReferences = None) must returnValueLike[Either[ErrorResp, AuthorizationInfo]] { 32 | case Right(r) => 33 | r.allowed === Allowed.Allowed 34 | } 35 | } 36 | 37 | "request authorization for a token on a specific location" in new TestScope { 38 | val testLocRefs = LocationReferences(locationId = LocationId("ABCDEF"), 39 | evseUids = List("evse-123456", "evse-1234567").map(EvseUid(_))) 40 | client.authorize(theirTokensEndpointUri, AuthToken[Ours]("auth"), tokenId, 41 | locationReferences = Some(testLocRefs)) must returnValueLike[Either[ErrorResp, AuthorizationInfo]] { 42 | case Right(r) => 43 | r.allowed === Allowed.Allowed 44 | } 45 | } 46 | 47 | 48 | "request authorization for a token and get it, when endpoint ends with a slash already" in new TestScope { 49 | val urlWithTrailingSlash = Uri(theirTokensEndpoint + "/") 50 | 51 | client.authorize(urlWithTrailingSlash, AuthToken[Ours]("auth"), tokenId, 52 | locationReferences = None) must returnValueLike[Either[ErrorResp, AuthorizationInfo]] { 53 | case Right(r) => 54 | r.allowed === Allowed.Allowed 55 | } 56 | } 57 | } 58 | 59 | trait TestScope extends Scope { 60 | 61 | implicit val system: ActorSystem = ActorSystem() 62 | 63 | implicit val http: HttpExt = Http() 64 | 65 | val tokenId = TokenUid("DEADBEEF") 66 | 67 | val theirTokensEndpoint = "http://localhost:8095/msp/versions/2.1/tokens" 68 | val theirTokensEndpointUri = Uri(theirTokensEndpoint) 69 | 70 | val authorizedResp = HttpResponse( 71 | OK, entity = HttpEntity(`application/json`, 72 | s""" 73 | |{ 74 | | "status_code": 1000, 75 | | "timestamp": "2010-01-01T00:00:00Z", 76 | | "data": { 77 | | "allowed": "ALLOWED", 78 | | "info": { 79 | | "language": "nl", 80 | | "text": "Ga je gang" 81 | | } 82 | | } 83 | |} 84 | """.stripMargin.getBytes) 85 | ) 86 | 87 | implicit val timeout: Timeout = Timeout(2.seconds) 88 | val tokenAuthorizeUri = s"$theirTokensEndpoint/$tokenId/authorize" 89 | 90 | def requestWithAuth(uri: String) = uri match { 91 | case `tokenAuthorizeUri` => IO.pure(authorizedResp) 92 | case x => IO.raiseError(new UnknownHostException(x.toString)) 93 | } 94 | 95 | lazy val client = new TestMspTokensClient(requestWithAuth) 96 | 97 | } 98 | } 99 | 100 | // generalize to testhttpclient? 101 | class TestMspTokensClient(reqWithAuthFunc: String => IO[HttpResponse]) 102 | (implicit httpExt: HttpExt) extends MspTokensClient[IO] { 103 | 104 | override def requestWithAuth(http: HttpExt, req: HttpRequest, token: AuthToken[Ours]) 105 | (implicit ec: ExecutionContext, mat: Materializer): Future[HttpResponse] = 106 | req.uri.toString match { 107 | case x => reqWithAuthFunc(x).unsafeToFuture() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /endpoints-msp-cdrs/src/main/scala/com/thenewmotion/ocpi/cdrs/CdrsClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package cdrs 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.NotUsed 6 | import _root_.akka.http.scaladsl.HttpExt 7 | import _root_.akka.http.scaladsl.model.Uri 8 | import _root_.akka.stream.Materializer 9 | import _root_.akka.stream.scaladsl.Source 10 | import cats.effect.{Async, ContextShift} 11 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, PagedRespUnMar, PaginatedSource} 12 | import com.thenewmotion.ocpi.msgs.AuthToken 13 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 14 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.Cdr 15 | import scala.concurrent.ExecutionContext 16 | 17 | class CdrsClient[F[_]: Async]( 18 | implicit http: HttpExt, 19 | successU: PagedRespUnMar[Cdr], 20 | errorU: ErrRespUnMar 21 | ) extends OcpiClient[F] { 22 | 23 | def getCdrs( 24 | uri: Uri, 25 | auth: AuthToken[Ours], 26 | dateFrom: Option[ZonedDateTime] = None, 27 | dateTo: Option[ZonedDateTime] = None, 28 | pageLimit: Int = OcpiClient.DefaultPageLimit 29 | )( 30 | implicit ec: ExecutionContext, 31 | cs: ContextShift[F], 32 | mat: Materializer 33 | ): F[ErrorRespOr[Iterable[Cdr]]] = 34 | traversePaginatedResource[Cdr](uri, auth, dateFrom, dateTo, pageLimit) 35 | 36 | def cdrsSource( 37 | uri: Uri, 38 | auth: AuthToken[Ours], 39 | dateFrom: Option[ZonedDateTime] = None, 40 | dateTo: Option[ZonedDateTime] = None, 41 | pageLimit: Int = OcpiClient.DefaultPageLimit 42 | )( 43 | implicit ec: ExecutionContext, 44 | mat: Materializer 45 | ): Source[Cdr, NotUsed] = 46 | PaginatedSource[Cdr](http, uri, auth, dateFrom, dateTo, pageLimit) 47 | 48 | } 49 | -------------------------------------------------------------------------------- /endpoints-msp-cdrs/src/main/scala/com/thenewmotion/ocpi/cdrs/CdrsError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package cdrs 3 | 4 | sealed trait CdrsError { 5 | def reason: Option[String] 6 | } 7 | 8 | object CdrsError { 9 | case class CdrNotFound(reason: Option[String] = None) extends CdrsError 10 | case class CdrCreationFailed(reason: Option[String] = None) extends CdrsError 11 | } 12 | -------------------------------------------------------------------------------- /endpoints-msp-cdrs/src/main/scala/com/thenewmotion/ocpi/cdrs/MspCdrsRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package cdrs 3 | 4 | import _root_.akka.http.scaladsl.marshalling.ToResponseMarshaller 5 | import _root_.akka.http.scaladsl.model.StatusCode 6 | import _root_.akka.http.scaladsl.model.StatusCodes._ 7 | import _root_.akka.http.scaladsl.server.Route 8 | import _root_.akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 9 | import cats.Functor 10 | import com.thenewmotion.ocpi.cdrs.CdrsError._ 11 | import com.thenewmotion.ocpi.common._ 12 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 13 | import com.thenewmotion.ocpi.msgs.{ErrorResp, _} 14 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.{Cdr, CdrId} 15 | 16 | object MspCdrsRoute { 17 | def apply[F[_]: Functor: HktMarshallable]( 18 | service: MspCdrsService[F] 19 | )( 20 | implicit errorM: ErrRespMar, 21 | successUnit: SuccessRespMar[Unit], 22 | successCdr: SuccessRespMar[Cdr], 23 | cdrU: FromEntityUnmarshaller[Cdr] 24 | ): MspCdrsRoute[F] = new MspCdrsRoute(service) 25 | } 26 | 27 | class MspCdrsRoute[F[_]: Functor: HktMarshallable] private[ocpi]( 28 | service: MspCdrsService[F] 29 | )( 30 | implicit errorM: ErrRespMar, 31 | successUnit: SuccessRespMar[Unit], 32 | successCdr: SuccessRespMar[Cdr], 33 | cdrU: FromEntityUnmarshaller[Cdr] 34 | ) extends EitherUnmarshalling 35 | with OcpiDirectives { 36 | 37 | implicit def cdrsErrorResp( 38 | implicit em: ToResponseMarshaller[(StatusCode, ErrorResp)] 39 | ): ToResponseMarshaller[CdrsError] = { 40 | em.compose[CdrsError] { cdrsError => 41 | val statusCode = cdrsError match { 42 | case _: CdrNotFound => NotFound 43 | case _: CdrCreationFailed => OK 44 | case _ => InternalServerError 45 | } 46 | statusCode -> ErrorResp(GenericClientFailure, cdrsError.reason) 47 | } 48 | } 49 | 50 | def apply( 51 | apiUser: GlobalPartyId 52 | ): Route = 53 | handleRejections(OcpiRejectionHandler.Default)(routeWithoutRh(apiUser)) 54 | 55 | private val CdrIdSegment = Segment.map(CdrId(_)) 56 | 57 | import HktMarshallableSyntax._ 58 | 59 | private[cdrs] def routeWithoutRh( 60 | apiUser: GlobalPartyId 61 | ) = { 62 | authPathPrefixGlobalPartyIdEquality(apiUser) { 63 | (post & pathEndOrSingleSlash) { 64 | entity(as[Cdr]) { cdr => 65 | complete { 66 | service.createCdr(apiUser, cdr).mapRight { _ => 67 | (Created, SuccessResp(GenericSuccess)) 68 | } 69 | } 70 | } 71 | } ~ 72 | (get & pathPrefix(CdrIdSegment) & pathEndOrSingleSlash) { cdrId => 73 | complete { 74 | service.cdr(apiUser, cdrId).mapRight { cdr => 75 | SuccessResp(GenericSuccess, data = cdr) 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /endpoints-msp-cdrs/src/main/scala/com/thenewmotion/ocpi/cdrs/MspCdrsService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package cdrs 3 | 4 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.{Cdr, CdrId} 5 | import msgs.GlobalPartyId 6 | 7 | 8 | /** 9 | * All methods are to be implemented in an idempotent fashion. 10 | */ 11 | trait MspCdrsService[F[_]] { 12 | 13 | /** 14 | * @return Either#Right if the cdr has been created 15 | */ 16 | def createCdr( 17 | globalPartyId: GlobalPartyId, 18 | cdr: Cdr 19 | ): F[Either[CdrsError, Unit]] 20 | 21 | /** 22 | * @return existing Cdr or Error if Cdr couldn't be found 23 | */ 24 | def cdr( 25 | globalPartyId: GlobalPartyId, 26 | cdrId: CdrId 27 | ): F[Either[CdrsError, Cdr]] 28 | } 29 | -------------------------------------------------------------------------------- /endpoints-msp-cdrs/src/test/scala/com/thenewmotion/ocpi/cdrs/MspCdrsRouteSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.cdrs 2 | 3 | import java.time.ZonedDateTime 4 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 5 | import akka.http.scaladsl.model.StatusCodes 6 | import akka.http.scaladsl.model.headers.Link 7 | import akka.http.scaladsl.testkit.Specs2RouteTest 8 | import cats.effect.IO 9 | import com.thenewmotion.ocpi.cdrs.CdrsError._ 10 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs._ 11 | import com.thenewmotion.ocpi.msgs.v2_1.Locations._ 12 | import com.thenewmotion.ocpi.msgs.{CountryCode, CurrencyCode, GlobalPartyId, SuccessResp} 13 | import org.specs2.mock.Mockito 14 | import org.specs2.mutable.Specification 15 | import org.specs2.specification.Scope 16 | import cats.syntax.either._ 17 | import com.thenewmotion.ocpi.common.OcpiDirectives 18 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 19 | 20 | class MspCdrsRouteSpec extends Specification with Specs2RouteTest with Mockito { 21 | 22 | "MspCdrsRoute" should { 23 | "return an existing Cdr" in new TestScope { 24 | service.cdr(apiUser, cdr.id) returns IO.pure(cdr.asRight) 25 | Get("/NL/TNM/12345") ~> route(apiUser) ~> check { 26 | header[Link] must beNone 27 | there was one(service).cdr(apiUser, cdr.id) 28 | val res = entityAs[SuccessResp[Cdr]] 29 | res.data mustEqual cdr 30 | } 31 | } 32 | 33 | "handle NotFound failure" in new TestScope { 34 | service.cdr(apiUser, CdrId("does-not-exist")) returns IO.pure(CdrNotFound().asLeft) 35 | 36 | Get("/NL/TNM/does-not-exist") ~> route(apiUser) ~> check { 37 | there was one(service).cdr(apiUser, CdrId("does-not-exist")) 38 | status mustEqual StatusCodes.NotFound 39 | } 40 | } 41 | 42 | "allow posting new cdr" in new TestScope { 43 | service.createCdr(apiUser, cdr) returns IO.pure(().asRight) 44 | 45 | Post("/NL/TNM", cdr) ~> route(apiUser) ~> check { 46 | there was one(service).createCdr(apiUser, cdr) 47 | status mustEqual StatusCodes.Created 48 | } 49 | } 50 | 51 | "not allow updating cdr" in new TestScope { 52 | service.createCdr(apiUser, cdr) returns IO.pure(CdrCreationFailed().asLeft) 53 | 54 | Post("/NL/TNM", cdr) ~> route(apiUser) ~> check { 55 | there was one(service).createCdr(apiUser, cdr) 56 | status mustEqual StatusCodes.OK 57 | } 58 | } 59 | } 60 | 61 | trait TestScope extends Scope with SprayJsonSupport with OcpiDirectives { 62 | val apiUser = GlobalPartyId("NL", "TNM") 63 | 64 | val cdr = Cdr( 65 | id = CdrId("12345"), 66 | startDateTime = ZonedDateTime.parse("2015-06-29T21:39:09Z"), 67 | stopDateTime = ZonedDateTime.parse("2015-06-29T23:37:32Z"), 68 | authId = "DE8ACC12E46L89", 69 | authMethod = AuthMethod.Whitelist, 70 | location = Location( 71 | LocationId("LOC1"), 72 | ZonedDateTime.parse("2015-06-29T21:39:01Z"), 73 | LocationType.OnStreet, 74 | Some("Gent Zuid"), 75 | "F.Rooseveltlaan 3A", 76 | "Gent", 77 | "9000", 78 | CountryCode("BEL"), 79 | GeoLocation(Latitude("3.72994"), Longitude("51.04759")), 80 | List(), 81 | List( 82 | Evse(EvseUid("3256"), ZonedDateTime.parse("2015-06-29T21:39:01Z"), ConnectorStatus.Available, 83 | List( 84 | Connector( 85 | ConnectorId("1"), 86 | ZonedDateTime.parse("2015-06-29T21:39:01Z"), 87 | ConnectorType.`IEC_62196_T2`, 88 | ConnectorFormat.Socket, 89 | PowerType.AC1Phase, 90 | 230, 91 | 64, 92 | Some("11") 93 | ) 94 | ), 95 | evseId = Some("BE-BEC-E041503003") 96 | ) 97 | ), 98 | chargingWhenClosed = None 99 | ), 100 | currency = CurrencyCode("EUR"), 101 | tariffs = None, 102 | chargingPeriods = List( 103 | ChargingPeriod(ZonedDateTime.parse("2015-06-29T21:39:09Z"), List(CdrDimension(CdrDimensionType.Time, 1.973))) 104 | ), 105 | totalCost = BigDecimal("4.00"), 106 | totalEnergy = 15.342, 107 | totalTime = 1.973, 108 | lastUpdated = ZonedDateTime.parse("2015-06-29T22:01:13Z") 109 | ) 110 | 111 | val service = mock[MspCdrsService[IO]] 112 | 113 | import com.thenewmotion.ocpi.common.HktMarshallableFromECInstances._ 114 | val route = MspCdrsRoute(service) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /endpoints-msp-commands/src/main/scala/com/thenewmotion/ocpi/commands/CommandClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package commands 3 | 4 | import _root_.akka.http.scaladsl.HttpExt 5 | import _root_.akka.http.scaladsl.client.RequestBuilding._ 6 | import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller 7 | import _root_.akka.http.scaladsl.model.Uri 8 | import _root_.akka.stream.Materializer 9 | import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 10 | import cats.effect.{Async, ContextShift} 11 | import cats.syntax.either._ 12 | import cats.syntax.functor._ 13 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient} 14 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 15 | import com.thenewmotion.ocpi.msgs.v2_1.Commands.{Command, CommandResponse, CommandResponseType} 16 | import com.thenewmotion.ocpi.msgs.{AuthToken, SuccessResp} 17 | import scala.concurrent.ExecutionContext 18 | 19 | class CommandClient[F[_]: Async]( 20 | implicit http: HttpExt, 21 | errorU: ErrRespUnMar, 22 | sucU: FromEntityUnmarshaller[Either[SuccessResp[CommandResponseType], SuccessResp[CommandResponse]]] 23 | ) extends OcpiClient[F] { 24 | 25 | def sendCommand[C <: Command : ToEntityMarshaller]( 26 | commandsUri: Uri, 27 | auth: AuthToken[Ours], 28 | command: C 29 | )( 30 | implicit ec: ExecutionContext, 31 | cs: ContextShift[F], 32 | mat: Materializer 33 | ): F[ErrorRespOr[CommandResponseType]] = { 34 | 35 | val commandUri = commandsUri.copy(path = commandsUri.path ?/ command.name.name) 36 | 37 | singleRequestRawT[Either[SuccessResp[CommandResponseType], SuccessResp[CommandResponse]]](Post(commandUri, command), auth).map { 38 | _.bimap(err => { 39 | logger.error(s"Could not post command to $commandUri. Reason: $err") 40 | err 41 | }, { 42 | case Left(crt) => crt.data 43 | case Right(cr) => cr.data.result 44 | }) 45 | } 46 | 47 | } 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /endpoints-msp-commands/src/main/scala/com/thenewmotion/ocpi/commands/CommandResponseRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package commands 3 | 4 | import java.util.UUID 5 | import _root_.akka.http.scaladsl.server.Route 6 | import _root_.akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 7 | import cats.Functor 8 | import com.thenewmotion.ocpi.common._ 9 | import com.thenewmotion.ocpi.msgs._ 10 | import com.thenewmotion.ocpi.msgs.v2_1.Commands.{CommandResponse, CommandResponseType} 11 | 12 | object CommandResponseRoute { 13 | def apply[F[_]: Functor: HktMarshallable]( 14 | callback: (GlobalPartyId, UUID, CommandResponseType) => F[Option[SuccessResp[Unit]]] 15 | )( 16 | implicit errorM: ErrRespMar, 17 | succM: SuccessRespMar[Unit], 18 | reqUm: FromEntityUnmarshaller[CommandResponse] 19 | ) = new CommandResponseRoute(callback) 20 | } 21 | 22 | class CommandResponseRoute[F[_]: Functor: HktMarshallable] private[ocpi]( 23 | callback: (GlobalPartyId, UUID, CommandResponseType) => F[Option[SuccessResp[Unit]]] 24 | )( 25 | implicit errorM: ErrRespMar, 26 | succM: SuccessRespMar[Unit], 27 | reqUm: FromEntityUnmarshaller[CommandResponse] 28 | ) extends EitherUnmarshalling 29 | with OcpiDirectives { 30 | 31 | def apply( 32 | apiUser: GlobalPartyId 33 | ): Route = 34 | handleRejections(OcpiRejectionHandler.Default)(routeWithoutRh(apiUser)) 35 | 36 | import HktMarshallableSyntax._ 37 | 38 | private[commands] def routeWithoutRh( 39 | apiUser: GlobalPartyId 40 | ) = 41 | (pathPrefix(JavaUUID) & pathEndOrSingleSlash) { commandId => 42 | post { 43 | entity(as[CommandResponse]) { response => 44 | rejectEmptyResponse { 45 | complete { 46 | callback(apiUser, commandId, response.result) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /endpoints-msp-commands/src/test/scala/com/thenewmotion/ocpi/commands/CommandResponseRouteSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.commands 2 | 3 | import java.util.UUID 4 | import akka.http.scaladsl.testkit.Specs2RouteTest 5 | import cats.effect.IO 6 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode.GenericSuccess 7 | import com.thenewmotion.ocpi.msgs.v2_1.Commands.{CommandResponse, CommandResponseType} 8 | import com.thenewmotion.ocpi.msgs.{GlobalPartyId, SuccessResp} 9 | import org.specs2.mutable.Specification 10 | import org.specs2.specification.Scope 11 | 12 | class CommandResponseRouteSpec extends Specification with Specs2RouteTest { 13 | 14 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ 15 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 16 | 17 | "command response endpoint" should { 18 | 19 | "receive a response for a valid command" in new TestScope { 20 | val uuid = UUID.fromString("fd1ff5e3-3ca1-4855-8ad2-c077dba0c67c") 21 | 22 | val body = CommandResponse(CommandResponseType.Accepted) 23 | 24 | Post(s"/$uuid", body) ~> route.routeWithoutRh(apiUser) ~> check { 25 | status.isSuccess === true 26 | responseAs[String] must contain(GenericSuccess.code.toString) 27 | } 28 | } 29 | } 30 | 31 | trait TestScope extends Scope { 32 | 33 | val apiUser = GlobalPartyId("NL", "TNM") 34 | 35 | import com.thenewmotion.ocpi.common.HktMarshallableFromECInstances._ 36 | val route = new CommandResponseRoute[IO]( (gpi, uuid, crt) => 37 | IO.pure(Some(SuccessResp(GenericSuccess))) 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /endpoints-msp-locations/src/main/scala/com/thenewmotion/ocpi/locations/LocationsClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package locations 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.NotUsed 6 | import _root_.akka.http.scaladsl.HttpExt 7 | import _root_.akka.http.scaladsl.model.Uri 8 | import _root_.akka.stream.Materializer 9 | import _root_.akka.stream.scaladsl.Source 10 | import cats.effect.{Async, ContextShift} 11 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, PagedRespUnMar, PaginatedSource} 12 | import com.thenewmotion.ocpi.msgs.AuthToken 13 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 14 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.Location 15 | import scala.concurrent.ExecutionContext 16 | 17 | class LocationsClient[F[_]: Async]( 18 | implicit http: HttpExt, 19 | successU: PagedRespUnMar[Location], 20 | errorU: ErrRespUnMar 21 | ) extends OcpiClient[F] { 22 | 23 | def getLocations( 24 | uri: Uri, 25 | auth: AuthToken[Ours], 26 | dateFrom: Option[ZonedDateTime] = None, 27 | dateTo: Option[ZonedDateTime] = None, 28 | pageLimit: Int = OcpiClient.DefaultPageLimit 29 | )( 30 | implicit ec: ExecutionContext, 31 | cs: ContextShift[F], 32 | mat: Materializer 33 | ): F[ErrorRespOr[Iterable[Location]]] = 34 | traversePaginatedResource[Location](uri, auth, dateFrom, dateTo, pageLimit) 35 | 36 | def locationsSource( 37 | uri: Uri, 38 | auth: AuthToken[Ours], 39 | dateFrom: Option[ZonedDateTime] = None, 40 | dateTo: Option[ZonedDateTime] = None, 41 | pageLimit: Int = OcpiClient.DefaultPageLimit 42 | )( 43 | implicit ec: ExecutionContext, 44 | mat: Materializer 45 | ): Source[Location, NotUsed] = 46 | PaginatedSource[Location](http, uri, auth, dateFrom, dateTo, pageLimit) 47 | 48 | } 49 | -------------------------------------------------------------------------------- /endpoints-msp-locations/src/main/scala/com/thenewmotion/ocpi/locations/LocationsError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package locations 3 | 4 | sealed trait LocationsError {def reason: Option[String]} 5 | 6 | object LocationsError { 7 | case class LocationNotFound(reason: Option[String] = None) extends LocationsError 8 | case class LocationCreationFailed(reason: Option[String] = None) extends LocationsError 9 | case class LocationUpdateFailed(reason: Option[String] = None) extends LocationsError 10 | case class EvseNotFound(reason: Option[String] = None) extends LocationsError 11 | case class EvseCreationFailed(reason: Option[String] = None) extends LocationsError 12 | case class ConnectorNotFound(reason: Option[String] = None) extends LocationsError 13 | case class ConnectorCreationFailed(reason: Option[String] = None) extends LocationsError 14 | case class IncorrectLocationId(reason: Option[String] = None) extends LocationsError 15 | } 16 | -------------------------------------------------------------------------------- /endpoints-msp-locations/src/main/scala/com/thenewmotion/ocpi/locations/MspLocationsService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package locations 3 | 4 | import cats.Applicative 5 | import com.thenewmotion.ocpi.common.CreateOrUpdateResult 6 | import msgs.GlobalPartyId 7 | import msgs.v2_1.Locations._ 8 | import cats.syntax.either._ 9 | import cats.syntax.option._ 10 | import com.thenewmotion.ocpi.locations.LocationsError.IncorrectLocationId 11 | 12 | /** 13 | * All methods are to be implemented in an idempotent fashion. 14 | */ 15 | trait MspLocationsService[F[_]] { 16 | 17 | protected[locations] def createOrUpdateLocation( 18 | apiUser: GlobalPartyId, 19 | locId: LocationId, 20 | loc: Location 21 | )( 22 | implicit A: Applicative[F] 23 | ): F[Either[LocationsError, CreateOrUpdateResult]] = { 24 | if (loc.id == locId) { 25 | createOrUpdateLocation(apiUser, loc) 26 | } else 27 | Applicative[F].pure( 28 | IncorrectLocationId(s"Token id from Url is $locId, but id in JSON body is ${loc.id}".some).asLeft 29 | ) 30 | } 31 | 32 | def createOrUpdateLocation( 33 | globalPartyId: GlobalPartyId, 34 | loc: Location 35 | ): F[Either[LocationsError, CreateOrUpdateResult]] 36 | 37 | def addOrUpdateEvse( 38 | globalPartyId: GlobalPartyId, 39 | locId: LocationId, 40 | evseUid: EvseUid, 41 | evse: Evse 42 | ): F[Either[LocationsError, CreateOrUpdateResult]] 43 | 44 | def addOrUpdateConnector( 45 | globalPartyId: GlobalPartyId, 46 | locId: LocationId, 47 | evseUid: EvseUid, 48 | connId: ConnectorId, 49 | connector: Connector 50 | ): F[Either[LocationsError, CreateOrUpdateResult]] 51 | 52 | def updateLocation( 53 | globalPartyId: GlobalPartyId, 54 | locId: LocationId, 55 | locPatch: LocationPatch 56 | ): F[Either[LocationsError, Unit]] 57 | 58 | def updateEvse( 59 | globalPartyId: GlobalPartyId, 60 | locId: LocationId, 61 | evseUid: EvseUid, 62 | evsePatch: EvsePatch 63 | ): F[Either[LocationsError, Unit]] 64 | 65 | def updateConnector( 66 | globalPartyId: GlobalPartyId, 67 | locId: LocationId, 68 | evseUid: EvseUid, 69 | connId: ConnectorId, 70 | connectorPatch: ConnectorPatch 71 | ): F[Either[LocationsError, Unit]] 72 | 73 | def location( 74 | globalPartyId: GlobalPartyId, 75 | locId: LocationId 76 | ): F[Either[LocationsError, Location]] 77 | 78 | def evse( 79 | globalPartyId: GlobalPartyId, 80 | locId: LocationId, 81 | evseUid: EvseUid 82 | ): F[Either[LocationsError, Evse]] 83 | 84 | def connector( 85 | globalPartyId: GlobalPartyId, 86 | locId: LocationId, 87 | evseUid: EvseUid, 88 | connectorId: ConnectorId 89 | ): F[Either[LocationsError, Connector]] 90 | 91 | } 92 | -------------------------------------------------------------------------------- /endpoints-msp-sessions/src/main/scala/com/thenewmotion/ocpi/sessions/SessionError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.sessions 2 | 3 | sealed trait SessionError {def reason: Option[String]} 4 | 5 | object SessionError { 6 | case class SessionNotFound(reason: Option[String] = None) extends SessionError 7 | case class IncorrectSessionId(reason: Option[String] = None) extends SessionError 8 | } 9 | -------------------------------------------------------------------------------- /endpoints-msp-sessions/src/main/scala/com/thenewmotion/ocpi/sessions/SessionsClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.sessions 2 | 3 | import java.time.ZonedDateTime 4 | import akka.NotUsed 5 | import akka.http.scaladsl.HttpExt 6 | import akka.http.scaladsl.model.Uri 7 | import akka.stream.Materializer 8 | import akka.stream.scaladsl.Source 9 | import cats.effect.{Async, ContextShift} 10 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, PagedRespUnMar, PaginatedSource} 11 | import com.thenewmotion.ocpi.msgs.AuthToken 12 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 13 | import com.thenewmotion.ocpi.msgs.v2_1.Sessions.Session 14 | import scala.concurrent.ExecutionContext 15 | 16 | class SessionsClient[F[_]: Async]( 17 | implicit http: HttpExt, 18 | successU: PagedRespUnMar[Session], 19 | errorU: ErrRespUnMar 20 | ) extends OcpiClient[F] { 21 | 22 | def getSessions( 23 | uri: Uri, 24 | auth: AuthToken[Ours], 25 | dateFrom: ZonedDateTime, 26 | dateTo: Option[ZonedDateTime] = None, 27 | pageLimit: Int = OcpiClient.DefaultPageLimit 28 | )( 29 | implicit ec: ExecutionContext, 30 | cs: ContextShift[F], 31 | mat: Materializer 32 | ): F[ErrorRespOr[Iterable[Session]]] = 33 | traversePaginatedResource[Session](uri, auth, Some(dateFrom), dateTo, pageLimit) 34 | 35 | def sessionsSource( 36 | uri: Uri, 37 | auth: AuthToken[Ours], 38 | dateFrom: ZonedDateTime, 39 | dateTo: Option[ZonedDateTime] = None, 40 | pageLimit: Int = OcpiClient.DefaultPageLimit 41 | )( 42 | implicit ec: ExecutionContext, 43 | mat: Materializer 44 | ): Source[Session, NotUsed] = 45 | PaginatedSource[Session](http, uri, auth, Some(dateFrom), dateTo, pageLimit) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /endpoints-msp-sessions/src/main/scala/com/thenewmotion/ocpi/sessions/SessionsRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package sessions 3 | 4 | import akka.http.scaladsl.marshalling.ToResponseMarshaller 5 | import akka.http.scaladsl.model.StatusCode 6 | import akka.http.scaladsl.model.StatusCodes._ 7 | import akka.http.scaladsl.server.Route 8 | import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 9 | import cats.Applicative 10 | import com.thenewmotion.ocpi.common._ 11 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 12 | import com.thenewmotion.ocpi.msgs.v2_1.Sessions.{Session, SessionId, SessionPatch} 13 | import com.thenewmotion.ocpi.msgs.{ErrorResp, GlobalPartyId, SuccessResp} 14 | import com.thenewmotion.ocpi.sessions.SessionError.{IncorrectSessionId, SessionNotFound} 15 | 16 | 17 | object SessionsRoute { 18 | def apply[F[_]: Applicative: HktMarshallable]( 19 | service: SessionsService[F] 20 | )( 21 | implicit locationU: FromEntityUnmarshaller[Session], 22 | locationPU: FromEntityUnmarshaller[SessionPatch], 23 | errorM: ErrRespMar, 24 | successUnitM: SuccessRespMar[Unit], 25 | successLocM: SuccessRespMar[Session] 26 | ): SessionsRoute[F] = new SessionsRoute(service) 27 | } 28 | 29 | class SessionsRoute[F[_]: Applicative: HktMarshallable] private[ocpi] ( 30 | service: SessionsService[F] 31 | )( 32 | implicit locationU: FromEntityUnmarshaller[Session], 33 | locationPU: FromEntityUnmarshaller[SessionPatch], 34 | errorM: ErrRespMar, 35 | successUnitM: SuccessRespMar[Unit], 36 | successLocM: SuccessRespMar[Session] 37 | ) extends EitherUnmarshalling 38 | with OcpiDirectives { 39 | 40 | implicit def sessionErrorResp( 41 | implicit em: ToResponseMarshaller[(StatusCode, ErrorResp)] 42 | ): ToResponseMarshaller[SessionError] = { 43 | em.compose[SessionError] { sessionError => 44 | val statusCode = sessionError match { 45 | case (_: SessionNotFound) => NotFound 46 | case (_: IncorrectSessionId) => BadRequest 47 | } 48 | statusCode -> ErrorResp(GenericClientFailure, sessionError.reason) 49 | } 50 | } 51 | 52 | def apply( 53 | apiUser: GlobalPartyId 54 | ): Route = 55 | handleRejections(OcpiRejectionHandler.Default)(routeWithoutRh(apiUser)) 56 | 57 | private val SessionIdSegment = Segment.map(SessionId(_)) 58 | 59 | import HktMarshallableSyntax._ 60 | 61 | private[sessions] def routeWithoutRh( 62 | apiUser: GlobalPartyId 63 | ) = { 64 | (authPathPrefixGlobalPartyIdEquality(apiUser) & pathPrefix(SessionIdSegment)) { sessionId => 65 | pathEndOrSingleSlash { 66 | put { 67 | entity(as[Session]) { session => 68 | complete { 69 | service.createOrUpdateSession(apiUser, sessionId, session).mapRight { x => 70 | (x.httpStatusCode, SuccessResp(GenericSuccess)) 71 | } 72 | } 73 | } 74 | } ~ 75 | patch { 76 | entity(as[SessionPatch]) { session => 77 | complete { 78 | service.updateSession(apiUser, sessionId, session).mapRight { _ => 79 | SuccessResp(GenericSuccess) 80 | } 81 | } 82 | } 83 | } ~ 84 | get { 85 | complete { 86 | service.session(apiUser, sessionId).mapRight { location => 87 | SuccessResp(GenericSuccess, None, data = location) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /endpoints-msp-sessions/src/main/scala/com/thenewmotion/ocpi/sessions/SessionsService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.sessions 2 | 3 | import cats.Applicative 4 | import com.thenewmotion.ocpi.common.CreateOrUpdateResult 5 | import com.thenewmotion.ocpi.msgs.GlobalPartyId 6 | import com.thenewmotion.ocpi.msgs.v2_1.Sessions.{Session, SessionId, SessionPatch} 7 | import com.thenewmotion.ocpi.sessions.SessionError.IncorrectSessionId 8 | import cats.syntax.either._ 9 | import cats.syntax.option._ 10 | 11 | /** 12 | * All methods are to be implemented in an idempotent fashion. 13 | */ 14 | trait SessionsService[F[_]] { 15 | 16 | protected[sessions] def createOrUpdateSession( 17 | apiUser: GlobalPartyId, 18 | sessionId: SessionId, 19 | session: Session 20 | )( 21 | implicit A: Applicative[F] 22 | ): F[Either[SessionError, CreateOrUpdateResult]] = { 23 | if (session.id == sessionId) { 24 | createOrUpdateSession(apiUser, session) 25 | } else 26 | Applicative[F].pure( 27 | IncorrectSessionId(s"Session id from Url is $sessionId, but id in JSON body is ${session.id}".some).asLeft 28 | ) 29 | } 30 | 31 | def createOrUpdateSession( 32 | globalPartyId: GlobalPartyId, 33 | session: Session 34 | ): F[Either[SessionError, CreateOrUpdateResult]] 35 | 36 | def updateSession( 37 | globalPartyId: GlobalPartyId, 38 | sessionId: SessionId, 39 | session: SessionPatch 40 | ): F[Either[SessionError, Unit]] 41 | 42 | def session( 43 | globalPartyId: GlobalPartyId, 44 | sessionId: SessionId 45 | ): F[Either[SessionError, Session]] 46 | 47 | } 48 | -------------------------------------------------------------------------------- /endpoints-msp-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/CpoTokensClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import _root_.akka.http.scaladsl.HttpExt 5 | import _root_.akka.http.scaladsl.client.RequestBuilding._ 6 | import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller 7 | import _root_.akka.http.scaladsl.model.{HttpRequest, Uri} 8 | import _root_.akka.stream.Materializer 9 | import cats.effect.{Async, ContextShift} 10 | import cats.syntax.either._ 11 | import cats.syntax.functor._ 12 | import com.thenewmotion.ocpi.common.{ClientObjectUri, ErrRespUnMar, OcpiClient, SuccessRespUnMar} 13 | import com.thenewmotion.ocpi.msgs.AuthToken 14 | import com.thenewmotion.ocpi.msgs.Ownership.Ours 15 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.{Token, TokenPatch, TokenUid} 16 | import scala.concurrent.ExecutionContext 17 | 18 | /** 19 | * Client that can be used by the MSP side 20 | * for pushing tokens to the CPO 21 | */ 22 | class CpoTokensClient[F[_]: Async]( 23 | implicit http: HttpExt, 24 | errorU: ErrRespUnMar, 25 | successTokenU: SuccessRespUnMar[Token], 26 | successUnitU: SuccessRespUnMar[Unit], 27 | tokenM: ToEntityMarshaller[Token], 28 | tokenPM: ToEntityMarshaller[TokenPatch] 29 | ) extends OcpiClient[F] { 30 | 31 | def getToken( 32 | tokenUri: ClientObjectUri, 33 | authToken: AuthToken[Ours], 34 | tokenUid: TokenUid 35 | )(implicit ec: ExecutionContext, 36 | cs: ContextShift[F], 37 | mat: Materializer 38 | ): F[ErrorRespOr[Token]] = 39 | singleRequest[Token](Get(tokenUri.value), authToken).map { 40 | _.bimap(err => { 41 | logger.error(s"Could not retrieve token from ${tokenUri.value}. Reason: $err") 42 | err 43 | }, _.data) 44 | } 45 | 46 | private def push( 47 | tokenUri: ClientObjectUri, 48 | authToken: AuthToken[Ours], 49 | rb: Uri => HttpRequest 50 | )(implicit ec: ExecutionContext, 51 | cs: ContextShift[F], 52 | mat: Materializer 53 | ): F[ErrorRespOr[Unit]] = 54 | singleRequest[Unit](rb(tokenUri.value), authToken).map { 55 | _.bimap(err => { 56 | logger.error(s"Could not upload token to ${tokenUri.value}. Reason: $err") 57 | err 58 | }, _ => ()) 59 | } 60 | 61 | def uploadToken( 62 | tokenUri: ClientObjectUri, 63 | authToken: AuthToken[Ours], 64 | token: Token 65 | )(implicit ec: ExecutionContext, 66 | cs: ContextShift[F], 67 | mat: Materializer 68 | ): F[ErrorRespOr[Unit]] = 69 | push(tokenUri, authToken, uri => Put(uri, token)) 70 | 71 | def updateToken( 72 | tokenUri: ClientObjectUri, 73 | authToken: AuthToken[Ours], 74 | patch: TokenPatch 75 | )( 76 | implicit ec: ExecutionContext, 77 | cs: ContextShift[F], 78 | mat: Materializer 79 | ): F[ErrorRespOr[Unit]] = 80 | push(tokenUri, authToken, uri => Patch(uri, patch)) 81 | 82 | } 83 | 84 | -------------------------------------------------------------------------------- /endpoints-msp-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/MspTokensRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import java.time.ZonedDateTime 5 | import _root_.akka.http.scaladsl.marshalling.{Marshaller, ToResponseMarshaller} 6 | import _root_.akka.http.scaladsl.model.StatusCode 7 | import _root_.akka.http.scaladsl.model.StatusCodes.{NotFound, OK} 8 | import _root_.akka.http.scaladsl.server.{Directive1, Route} 9 | import _root_.akka.http.scaladsl.unmarshalling.{FromEntityUnmarshaller, FromRequestUnmarshaller} 10 | import cats.effect.Effect 11 | import com.thenewmotion.ocpi.common._ 12 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 13 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.{AuthorizationInfo, LocationReferences, Token, TokenUid} 14 | import com.thenewmotion.ocpi.msgs.{ErrorResp, GlobalPartyId, SuccessResp} 15 | import com.thenewmotion.ocpi.tokens.AuthorizeError._ 16 | import scala.concurrent.ExecutionContext 17 | 18 | object MspTokensRoute { 19 | def apply[F[_]: Effect: HktMarshallable]( 20 | service: MspTokensService[F], 21 | DefaultLimit: Int = 1000, 22 | MaxLimit: Int = 1000, 23 | linkHeaderScheme: Option[String] = None 24 | )( 25 | implicit pagTokensM: SuccessRespMar[Iterable[Token]], 26 | authM: SuccessRespMar[AuthorizationInfo], 27 | errorM: ErrRespMar, 28 | locationReferencesU: FromEntityUnmarshaller[LocationReferences] 29 | ) = new MspTokensRoute(service, DefaultLimit, MaxLimit, linkHeaderScheme) 30 | } 31 | 32 | class MspTokensRoute[F[_]: Effect: HktMarshallable] private[ocpi]( 33 | service: MspTokensService[F], 34 | val DefaultLimit: Int, 35 | val MaxLimit: Int, 36 | override val linkHeaderScheme: Option[String] = None 37 | )( 38 | implicit pagTokensM: SuccessRespMar[Iterable[Token]], 39 | authM: SuccessRespMar[AuthorizationInfo], 40 | errorM: ErrRespMar, 41 | locationReferencesU: FromEntityUnmarshaller[LocationReferences] 42 | ) extends OcpiDirectives 43 | with PaginatedRoute 44 | with EitherUnmarshalling { 45 | 46 | implicit def locationsErrorResp( 47 | implicit errorMarshaller: ToResponseMarshaller[(StatusCode, ErrorResp)], 48 | statusMarshaller: ToResponseMarshaller[StatusCode] 49 | ): ToResponseMarshaller[AuthorizeError] = 50 | Marshaller { implicit ex: ExecutionContext => 51 | { 52 | case _: MustProvideLocationReferences.type => errorMarshaller(OK -> ErrorResp(NotEnoughInformation)) 53 | case _: TokenNotFound.type => statusMarshaller(NotFound) 54 | } 55 | } 56 | 57 | // akka-http doesn't handle optional entity, see https://github.com/akka/akka-http/issues/284 58 | def optionalEntity[T](unmarshaller: FromRequestUnmarshaller[T]): Directive1[Option[T]] = 59 | entity(as[String]).flatMap { stringEntity => 60 | if (stringEntity == null || stringEntity.isEmpty) { 61 | provide(Option.empty[T]) 62 | } else { 63 | entity(unmarshaller).flatMap(e => provide(Some(e))) 64 | } 65 | } 66 | 67 | private val TokenUidSegment = Segment.map(TokenUid(_)) 68 | 69 | import HktMarshallableSyntax._ 70 | 71 | def apply( 72 | apiUser: GlobalPartyId 73 | ): Route = 74 | pathEndOrSingleSlash { 75 | get { 76 | paged { (pager: Pager, dateFrom: Option[ZonedDateTime], dateTo: Option[ZonedDateTime]) => 77 | onSuccess(Effect[F].toIO(service.tokens(apiUser, pager, dateFrom, dateTo)).unsafeToFuture()) { pagTokens => 78 | respondWithPaginationHeaders(pager, pagTokens) { 79 | complete(SuccessResp(GenericSuccess, data = pagTokens.result)) 80 | } 81 | } 82 | } 83 | } 84 | } ~ 85 | pathPrefix(TokenUidSegment) { tokenUid => 86 | path("authorize") { 87 | (post & optionalEntity(as[LocationReferences])) { lr => 88 | complete { 89 | service.authorize(apiUser, tokenUid, lr).mapRight( authInfo => 90 | SuccessResp(GenericSuccess, data = authInfo) 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /endpoints-msp-tokens/src/main/scala/com/thenewmotion/ocpi/tokens/MspTokensService.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.tokens 2 | 3 | import java.time.ZonedDateTime 4 | import com.thenewmotion.ocpi.common.{Pager, PaginatedResult} 5 | import com.thenewmotion.ocpi.msgs.GlobalPartyId 6 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.{AuthorizationInfo, LocationReferences, Token, TokenUid} 7 | 8 | sealed trait AuthorizeError 9 | 10 | object AuthorizeError { 11 | case object MustProvideLocationReferences extends AuthorizeError 12 | case object TokenNotFound extends AuthorizeError 13 | } 14 | 15 | /** 16 | * All methods are to be implemented in an idempotent fashion. 17 | */ 18 | trait MspTokensService[F[_]] { 19 | def tokens( 20 | globalPartyId: GlobalPartyId, 21 | pager: Pager, 22 | dateFrom: Option[ZonedDateTime] = None, 23 | dateTo: Option[ZonedDateTime] = None 24 | ): F[PaginatedResult[Token]] 25 | 26 | def authorize( 27 | globalPartyId: GlobalPartyId, 28 | tokenUid: TokenUid, 29 | locationReferences: Option[LocationReferences] 30 | ): F[Either[AuthorizeError, AuthorizationInfo]] 31 | } 32 | -------------------------------------------------------------------------------- /endpoints-msp-tokens/src/test/scala/com/thenewmotion/ocpi/tokens/MspTokensRouteSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package tokens 3 | 4 | import java.time.ZonedDateTime 5 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 6 | import akka.http.scaladsl.model.StatusCodes 7 | import akka.http.scaladsl.model.headers.{Link, RawHeader} 8 | import akka.http.scaladsl.testkit.Specs2RouteTest 9 | import cats.effect.IO 10 | import cats.syntax.either._ 11 | import com.thenewmotion.ocpi.common._ 12 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 13 | import com.thenewmotion.ocpi.msgs._ 14 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 15 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.{ConnectorId, EvseUid, LocationId} 16 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens._ 17 | import com.thenewmotion.ocpi.tokens.AuthorizeError._ 18 | import org.specs2.mock.Mockito 19 | import org.specs2.mutable.Specification 20 | import org.specs2.specification.Scope 21 | 22 | class MspTokensRouteSpec extends Specification with Specs2RouteTest with Mockito { 23 | 24 | "MspTokensRoute" should { 25 | "return a paged set of Tokens" in new TestScope { 26 | service.tokens(apiUser, Pager(0, 1000), None, None) returns IO.pure(PaginatedResult(List(token), 1)) 27 | Get() ~> route(apiUser) ~> check { 28 | header[Link] must beNone 29 | headers.find(_.name == "X-Limit") mustEqual Some(RawHeader("X-Limit", "1000")) 30 | headers.find(_.name == "X-Total-Count") mustEqual Some(RawHeader("X-Total-Count", "1")) 31 | there was one(service).tokens(apiUser, Pager(0, 1000), None, None) 32 | val res = entityAs[SuccessResp[List[Token]]] 33 | res.data mustEqual List(token) 34 | } 35 | } 36 | 37 | "authorize without location references" in new TestScope { 38 | service.authorize(apiUser, TokenUid("23455655A"), None) returns IO.pure(AuthorizationInfo(Allowed.Allowed).asRight) 39 | 40 | Post("/23455655A/authorize") ~> route(apiUser) ~> check { 41 | there was one(service).authorize(apiUser, TokenUid("23455655A"), None) 42 | val res = entityAs[SuccessResp[AuthorizationInfo]] 43 | res.data.allowed mustEqual Allowed.Allowed 44 | } 45 | } 46 | 47 | "authorize with location references" in new TestScope { 48 | 49 | val lr = LocationReferences(LocationId("1234"), List(EvseUid("1234")), List(ConnectorId("1234"), ConnectorId("5678"))) 50 | 51 | service.authorize(apiUser, TokenUid("23455655A"), Some(lr)) returns IO.pure(AuthorizationInfo(Allowed.Allowed).asRight) 52 | 53 | Post("/23455655A/authorize", lr) ~> route(apiUser) ~> check { 54 | there was one(service).authorize(apiUser, TokenUid("23455655A"), Some(lr)) 55 | val res = entityAs[SuccessResp[AuthorizationInfo]] 56 | res.data.allowed mustEqual Allowed.Allowed 57 | } 58 | } 59 | 60 | "handle MustProvideLocationReferences failure" in new TestScope { 61 | service.authorize(apiUser, TokenUid("23455655A"), None) returns IO.pure(MustProvideLocationReferences.asLeft) 62 | 63 | Post("/23455655A/authorize") ~> route(apiUser) ~> check { 64 | there was one(service).authorize(apiUser, TokenUid("23455655A"), None) 65 | val res = entityAs[ErrorResp] 66 | res.statusCode mustEqual NotEnoughInformation 67 | status mustEqual StatusCodes.OK 68 | } 69 | } 70 | 71 | "handle NotFound failure" in new TestScope { 72 | service.authorize(apiUser, TokenUid("23455655A"), None) returns IO.pure(TokenNotFound.asLeft) 73 | 74 | Post("/23455655A/authorize") ~> route(apiUser) ~> check { 75 | there was one(service).authorize(apiUser, TokenUid("23455655A"), None) 76 | status mustEqual StatusCodes.NotFound 77 | } 78 | } 79 | } 80 | 81 | trait TestScope extends Scope with OcpiDirectives with SprayJsonSupport { 82 | val apiUser = GlobalPartyId("NL", "TNM") 83 | 84 | val token = Token( 85 | uid = TokenUid("23455655A"), 86 | `type` = TokenType.Rfid, 87 | authId = AuthId("NL-TNM-000660755-V"), 88 | visualNumber = Some("NL-TNM-066075-5"), 89 | issuer = "TheNewMotion", 90 | valid = true, 91 | whitelist = WhitelistType.Allowed, 92 | lastUpdated = ZonedDateTime.parse("2017-01-24T10:00:00.000Z") 93 | ) 94 | 95 | import HktMarshallableFromECInstances._ 96 | 97 | val service = mock[MspTokensService[IO]] 98 | val route = MspTokensRoute(service) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /endpoints-registration/src/main/scala/com/thenewmotion/ocpi/registration/ErrorMarshalling.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import _root_.akka.http.scaladsl.marshalling.ToResponseMarshaller 5 | import _root_.akka.http.scaladsl.model.StatusCode 6 | import _root_.akka.http.scaladsl.model.StatusCodes._ 7 | import common.EitherUnmarshalling 8 | import RegistrationError._ 9 | import msgs.{ErrorResp, OcpiStatusCode} 10 | import OcpiStatusCode._ 11 | 12 | object ErrorMarshalling extends EitherUnmarshalling { 13 | implicit def registrationErrorToResponseMarshaller( 14 | implicit mar: ToResponseMarshaller[(StatusCode, ErrorResp)] 15 | ): ToResponseMarshaller[RegistrationError] = 16 | mar.compose[RegistrationError] { e => 17 | val (status, cec) = e match { 18 | case VersionsRetrievalFailed => (FailedDependency, UnableToUseApi) 19 | case VersionDetailsRetrievalFailed => (FailedDependency, UnableToUseApi) 20 | case SendingCredentialsFailed => (BadRequest, UnableToUseApi) 21 | case UpdatingCredentialsFailed => (BadRequest, UnableToUseApi) 22 | case SelectedVersionNotHostedByUs(_) => (BadRequest, UnsupportedVersion) 23 | case CouldNotFindMutualVersion => (BadRequest, UnsupportedVersion) 24 | case SelectedVersionNotHostedByThem(_) => (BadRequest, UnsupportedVersion) 25 | case RegistrationError.UnknownEndpointType(_) => (InternalServerError, OcpiStatusCode.UnknownEndpointType) 26 | case AlreadyExistingParty(_) => (MethodNotAllowed, PartyAlreadyRegistered) 27 | case UnknownParty(_) => (BadRequest, AuthenticationFailed) 28 | case WaitingForRegistrationRequest(_) => (MethodNotAllowed, RegistrationNotCompletedYetByParty) 29 | case CouldNotUnregisterParty(_) => (MethodNotAllowed, ClientWasNotRegistered) 30 | } 31 | 32 | (status, ErrorResp(cec, Some(e.reason))) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /endpoints-registration/src/main/scala/com/thenewmotion/ocpi/registration/RegistrationClient.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import _root_.akka.http.scaladsl.HttpExt 5 | import _root_.akka.http.scaladsl.client.RequestBuilding._ 6 | import _root_.akka.http.scaladsl.marshalling.ToEntityMarshaller 7 | import _root_.akka.http.scaladsl.model.Uri 8 | import _root_.akka.stream.Materializer 9 | import cats.effect.{Async, ContextShift} 10 | import cats.syntax.either._ 11 | import cats.syntax.functor._ 12 | import cats.syntax.applicativeError._ 13 | import com.thenewmotion.ocpi.common.{ErrRespUnMar, OcpiClient, SuccessRespUnMar} 14 | import com.thenewmotion.ocpi.msgs.Ownership.{Ours, Theirs} 15 | import com.thenewmotion.ocpi.msgs.Versions._ 16 | import com.thenewmotion.ocpi.msgs.v2_1.Credentials.Creds 17 | import com.thenewmotion.ocpi.msgs.{AuthToken, Url} 18 | import com.thenewmotion.ocpi.registration.RegistrationError._ 19 | import scala.concurrent.ExecutionContext 20 | 21 | class RegistrationClient[F[_]: Async]( 22 | implicit http: HttpExt, 23 | errorU: ErrRespUnMar, 24 | sucListVerU: SuccessRespUnMar[List[Version]], 25 | sucVerDetU: SuccessRespUnMar[VersionDetails], 26 | sucCredsU: SuccessRespUnMar[Creds[Theirs]], 27 | credsM: ToEntityMarshaller[Creds[Ours]] 28 | ) extends OcpiClient[F] { 29 | 30 | def getTheirVersions( 31 | uri: Uri, 32 | token: AuthToken[Ours] 33 | )( 34 | implicit ec: ExecutionContext, 35 | cs: ContextShift[F], 36 | mat: Materializer 37 | ): F[Either[RegistrationError, List[Version]]] = { 38 | def errorMsg = s"Could not retrieve the versions information from $uri with token $token." 39 | val regError: RegistrationError = VersionsRetrievalFailed 40 | 41 | singleRequest[List[Version]](Get(uri), token) map { 42 | _.bimap(err => { 43 | logger.error(errorMsg + s" Reason: $err"); regError 44 | }, _.data) 45 | } handleErrorWith { t => logger.error(errorMsg, t); Async[F].pure(regError.asLeft) } 46 | } 47 | 48 | def getTheirVersionDetails( 49 | uri: Uri, 50 | token: AuthToken[Ours] 51 | )( 52 | implicit ec: ExecutionContext, 53 | cs: ContextShift[F], 54 | mat: Materializer 55 | ): F[Either[RegistrationError, VersionDetails]] = { 56 | def errorMsg = s"Could not retrieve the version details from $uri with token $token." 57 | val regError: RegistrationError = VersionDetailsRetrievalFailed 58 | 59 | singleRequest[VersionDetails](Get(uri), token) map { 60 | _.bimap(err => { 61 | logger.error(errorMsg + s" Reason: $err"); regError 62 | }, _.data) 63 | } handleErrorWith { t => logger.error(errorMsg, t); Async[F].pure(regError.asLeft) } 64 | } 65 | 66 | def sendCredentials( 67 | theirCredUrl: Url, 68 | tokenToConnectToThem: AuthToken[Ours], 69 | credToConnectToUs: Creds[Ours] 70 | )( 71 | implicit ec: ExecutionContext, 72 | cs: ContextShift[F], 73 | mat: Materializer 74 | ): F[Either[RegistrationError, Creds[Theirs]]] = { 75 | def errorMsg = s"Could not retrieve their credentials from $theirCredUrl with token " + 76 | s"$tokenToConnectToThem when sending our credentials $credToConnectToUs." 77 | val regError: RegistrationError = SendingCredentialsFailed 78 | 79 | singleRequest[Creds[Theirs]]( 80 | Post(theirCredUrl.value, credToConnectToUs), tokenToConnectToThem 81 | ) map { 82 | _.bimap(err => { 83 | logger.error(errorMsg + s" Reason: $err"); regError 84 | }, _.data) 85 | } handleErrorWith { t => logger.error(errorMsg, t); Async[F].pure(regError.asLeft) } 86 | } 87 | 88 | def updateCredentials( 89 | theirCredUrl: Url, 90 | tokenToConnectToThem: AuthToken[Ours], 91 | credToConnectToUs: Creds[Ours] 92 | )( 93 | implicit ec: ExecutionContext, 94 | cs: ContextShift[F], 95 | mat: Materializer 96 | ): F[Either[RegistrationError, Creds[Theirs]]] = { 97 | def errorMsg = s"Could not retrieve their credentials from $theirCredUrl with token" + 98 | s"$tokenToConnectToThem when sending our credentials $credToConnectToUs." 99 | val regError: RegistrationError = UpdatingCredentialsFailed 100 | 101 | singleRequest[Creds[Theirs]]( 102 | Put(theirCredUrl.value, credToConnectToUs), tokenToConnectToThem 103 | ) map { 104 | _.bimap(err => { 105 | logger.error(errorMsg + s" Reason: $err"); regError 106 | }, _.data) 107 | } handleErrorWith { t => logger.error(errorMsg, t); Async[F].pure(regError.asLeft) } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /endpoints-registration/src/main/scala/com/thenewmotion/ocpi/registration/RegistrationError.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import msgs.Versions.VersionNumber 5 | import msgs.GlobalPartyId 6 | 7 | sealed abstract class RegistrationError(val reason: String) 8 | 9 | object RegistrationError{ 10 | case object VersionsRetrievalFailed extends RegistrationError( 11 | "Failed versions retrieval.") 12 | 13 | case object VersionDetailsRetrievalFailed extends RegistrationError( 14 | s"Failed version details retrieval.") 15 | 16 | case object SendingCredentialsFailed extends RegistrationError( 17 | "Failed sending the credentials to connect to us.") 18 | 19 | case object UpdatingCredentialsFailed extends RegistrationError( 20 | "Failed updating the credentials to connect to us.") 21 | 22 | case class SelectedVersionNotHostedByUs(version: VersionNumber) extends RegistrationError( 23 | s"The selected version: $version, is not supported by our systems.") 24 | 25 | case object CouldNotFindMutualVersion extends RegistrationError( 26 | "Could not find mutual version.") 27 | 28 | case class SelectedVersionNotHostedByThem(version: VersionNumber) extends RegistrationError( 29 | s"Selected version: $version, not supported by the requester party systems.") 30 | 31 | case class UnknownEndpointType(endpointType: String) extends RegistrationError( 32 | s"Unknown endpoint type: $endpointType") 33 | 34 | case class AlreadyExistingParty(globalPartyId: GlobalPartyId) extends RegistrationError( 35 | s"Already existing global partyId: '$globalPartyId'") 36 | 37 | case class UnknownParty(globalPartyId: GlobalPartyId) extends RegistrationError( 38 | s"Unknown global partyId: '$globalPartyId") 39 | 40 | case class WaitingForRegistrationRequest(globalPartyId: GlobalPartyId) extends RegistrationError( 41 | "Still waiting for registration request.") 42 | 43 | case class CouldNotUnregisterParty(globalPartyId: GlobalPartyId) extends RegistrationError( 44 | s"Client is not registered for partyId: $globalPartyId") 45 | } 46 | -------------------------------------------------------------------------------- /endpoints-registration/src/main/scala/com/thenewmotion/ocpi/registration/RegistrationRepo.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import msgs.Ownership.Theirs 5 | import msgs.Versions.{Endpoint, VersionNumber} 6 | import msgs.v2_1.Credentials.Creds 7 | import msgs.{AuthToken, GlobalPartyId} 8 | 9 | trait RegistrationRepo[F[_]] { 10 | 11 | def isPartyRegistered( 12 | globalPartyId: GlobalPartyId 13 | ): F[Boolean] 14 | 15 | def findTheirAuthToken( 16 | globalPartyId: GlobalPartyId 17 | ): F[Option[AuthToken[Theirs]]] 18 | 19 | // Called after a 3rd party has called our credentials endpoint with a POST or a PUT 20 | def persistInfoAfterConnectToUs( 21 | globalPartyId: GlobalPartyId, 22 | version: VersionNumber, 23 | token: AuthToken[Theirs], 24 | creds: Creds[Theirs], 25 | endpoints: Iterable[Endpoint] 26 | ): F[Unit] 27 | 28 | // Called after _we_ start the registration by calling _their_ credentials endpoint with a POST or a PUT 29 | def persistInfoAfterConnectToThem( 30 | version: VersionNumber, 31 | token: AuthToken[Theirs], 32 | creds: Creds[Theirs], 33 | endpoints: Iterable[Endpoint] 34 | ): F[Unit] 35 | 36 | def deletePartyInformation( 37 | globalPartyId: GlobalPartyId 38 | ): F[Unit] 39 | } 40 | -------------------------------------------------------------------------------- /endpoints-registration/src/main/scala/com/thenewmotion/ocpi/registration/RegistrationRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import _root_.akka.http.scaladsl.server.Route 5 | import _root_.akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller 6 | import _root_.akka.stream.Materializer 7 | import cats.effect.Effect 8 | import com.thenewmotion.ocpi.common._ 9 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode.GenericSuccess 10 | import com.thenewmotion.ocpi.msgs.Ownership.{Ours, Theirs} 11 | import com.thenewmotion.ocpi.msgs.Versions.VersionNumber 12 | import com.thenewmotion.ocpi.msgs.v2_1.Credentials.Creds 13 | import com.thenewmotion.ocpi.msgs.{GlobalPartyId, SuccessResp} 14 | import scala.concurrent.ExecutionContext 15 | 16 | object RegistrationRoute { 17 | def apply[F[_]: Effect: HktMarshallable]( 18 | service: RegistrationService[F] 19 | )( 20 | implicit mat: Materializer, 21 | errorM: ErrRespMar, 22 | succOurCredsM: SuccessRespMar[Creds[Ours]], 23 | succUnitM: SuccessRespMar[Unit], 24 | theirCredsU: FromEntityUnmarshaller[Creds[Theirs]] 25 | ): RegistrationRoute[F] = new RegistrationRoute(service) 26 | } 27 | 28 | class RegistrationRoute[F[_]: Effect: HktMarshallable] private[ocpi]( 29 | service: RegistrationService[F] 30 | )( 31 | implicit mat: Materializer, 32 | errorM: ErrRespMar, 33 | succOurCredsM: SuccessRespMar[Creds[Ours]], 34 | succUnitM: SuccessRespMar[Unit], 35 | theirCredsU: FromEntityUnmarshaller[Creds[Theirs]] 36 | ) extends OcpiDirectives { 37 | 38 | import ErrorMarshalling._ 39 | import HktMarshallableSyntax._ 40 | 41 | def apply( 42 | accessedVersion: VersionNumber, 43 | user: GlobalPartyId 44 | )( 45 | implicit ec: ExecutionContext 46 | ): Route = { 47 | post { 48 | entity(as[Creds[Theirs]]) { credsToConnectToThem => 49 | complete { 50 | service 51 | .reactToNewCredsRequest(user, accessedVersion, credsToConnectToThem) 52 | .mapRight(x => SuccessResp(GenericSuccess, data = x)) 53 | } 54 | } 55 | } ~ 56 | get { 57 | complete { 58 | service 59 | .credsToConnectToUs(user) 60 | .mapRight(x => SuccessResp(GenericSuccess, data = x)) 61 | } 62 | } ~ 63 | put { 64 | entity(as[Creds[Theirs]]) { credsToConnectToThem => 65 | complete { 66 | service 67 | .reactToUpdateCredsRequest(user, accessedVersion, credsToConnectToThem) 68 | .mapRight(x => SuccessResp(GenericSuccess, data = x)) 69 | } 70 | } 71 | } ~ 72 | delete { 73 | complete { 74 | service 75 | .reactToDeleteCredsRequest(user) 76 | .mapRight(x => SuccessResp(GenericSuccess, data = x)) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /endpoints-registration/src/test/scala/com/thenewmotion/ocpi/registration/RegistrationClientSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package registration 3 | 4 | import akka.actor.ActorSystem 5 | import akka.http.scaladsl.HttpExt 6 | import akka.http.scaladsl.model.Uri 7 | import cats.effect.IO 8 | import com.thenewmotion.ocpi.common.IOMatchersExt 9 | import com.thenewmotion.ocpi.msgs.Ownership.{Ours, Theirs} 10 | import com.thenewmotion.ocpi.msgs.v2_1.CommonTypes.BusinessDetails 11 | import com.thenewmotion.ocpi.msgs.v2_1.Credentials.Creds 12 | import com.thenewmotion.ocpi.msgs.{AuthToken, GlobalPartyId, Url, Versions} 13 | import com.thenewmotion.ocpi.registration.RegistrationError.{SendingCredentialsFailed, UpdatingCredentialsFailed, VersionDetailsRetrievalFailed, VersionsRetrievalFailed} 14 | import org.specs2.concurrent.ExecutionEnv 15 | import org.specs2.matcher.EitherMatchers 16 | import org.specs2.mock.Mockito 17 | import org.specs2.mutable.Specification 18 | import org.specs2.specification.Scope 19 | import org.specs2.specification.core.Env 20 | import scala.concurrent.{ExecutionContext, Future} 21 | 22 | class RegistrationClientSpec(environment: Env) 23 | extends Specification 24 | with Mockito 25 | with IOMatchersExt 26 | with EitherMatchers { 27 | 28 | implicit val ee: ExecutionEnv = environment.executionEnv 29 | implicit val ec: ExecutionContext = environment.executionContext 30 | implicit val sys: ActorSystem = ActorSystem() 31 | 32 | val uri = Uri("http://localhost/nothingHere") 33 | val ourToken = AuthToken[Ours]("token") 34 | val creds = Creds[Ours]( 35 | AuthToken[Theirs]("token"), 36 | Url("http://localhost/norHere"), 37 | BusinessDetails("someOne", None, None), 38 | GlobalPartyId("party") 39 | ) 40 | 41 | "Registration client recovers errors when" >> { 42 | "getting their versions" >> new TestScope { 43 | client.getTheirVersions(uri, ourToken) must returnValueLike[Either[RegistrationError, List[Versions.Version]]]{ 44 | case Left(VersionsRetrievalFailed ) => ok 45 | } 46 | } 47 | "getting their version details" >> new TestScope { 48 | client.getTheirVersionDetails(uri, ourToken) must returnValueLike[Either[RegistrationError, Versions.VersionDetails]]{ 49 | case Left(VersionDetailsRetrievalFailed ) => ok 50 | } 51 | } 52 | "sending credentials" >> new TestScope { 53 | client.sendCredentials(Url("url"), ourToken, creds) must returnValueLike[Either[RegistrationError, Creds[Theirs]]]{ 54 | case Left(SendingCredentialsFailed ) => ok 55 | } 56 | } 57 | "updating credentials" >> new TestScope { 58 | client.updateCredentials(Url("url"), ourToken, creds) must returnValueLike[Either[RegistrationError, Creds[Theirs]]]{ 59 | case Left(UpdatingCredentialsFailed ) => ok 60 | } 61 | } 62 | } 63 | 64 | trait TestScope extends Scope { 65 | implicit val httpExt = mock[HttpExt] 66 | httpExt.singleRequest(any(), any(), any(), any()) returns Future.failed(new RuntimeException) 67 | 68 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ 69 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 70 | 71 | val client = new RegistrationClient[IO]() 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /endpoints-versions/src/main/scala/com/thenewmotion/ocpi/VersionRejections.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import akka.http.scaladsl.model.StatusCodes.OK 4 | import akka.http.scaladsl.server.Directives.complete 5 | import akka.http.scaladsl.server.{Rejection, RejectionHandler} 6 | import common.{ErrRespMar, OcpiRejectionHandler} 7 | import msgs.OcpiStatusCode.UnsupportedVersion 8 | import msgs.ErrorResp 9 | import msgs.Versions.VersionNumber 10 | 11 | object VersionRejections { 12 | case class UnsupportedVersionRejection(version: VersionNumber) extends Rejection 13 | case class NoVersionsRejection() extends Rejection 14 | 15 | def Handler( 16 | implicit errorRespM: ErrRespMar 17 | ): RejectionHandler = RejectionHandler.newBuilder().handle { 18 | case UnsupportedVersionRejection(version: VersionNumber) => complete { 19 | ( OK, 20 | ErrorResp( 21 | UnsupportedVersion, 22 | Some(s"Unsupported version: $version"))) 23 | } 24 | }.result().withFallback(OcpiRejectionHandler.Default) 25 | } 26 | -------------------------------------------------------------------------------- /endpoints-versions/src/main/scala/com/thenewmotion/ocpi/VersionsRoute.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import java.time.ZonedDateTime 4 | import _root_.akka.http.scaladsl.model.Uri 5 | import _root_.akka.http.scaladsl.server.{PathMatcher1, Route} 6 | import cats.effect.Effect 7 | import com.thenewmotion.ocpi 8 | import com.thenewmotion.ocpi.VersionRejections._ 9 | import com.thenewmotion.ocpi.VersionsRoute.OcpiVersionConfig 10 | import com.thenewmotion.ocpi.common._ 11 | import com.thenewmotion.ocpi.msgs.OcpiStatusCode._ 12 | import com.thenewmotion.ocpi.msgs.Versions._ 13 | import com.thenewmotion.ocpi.msgs.{GlobalPartyId, SuccessResp, Url, Versions} 14 | 15 | object VersionsRoute { 16 | 17 | def apply[F[_]: Effect: HktMarshallable]( 18 | versions: => F[Map[VersionNumber, OcpiVersionConfig]] 19 | )( 20 | implicit successRespListVerM: SuccessRespMar[List[Versions.Version]], 21 | successVerDetM: SuccessRespMar[VersionDetails], 22 | errorM: ErrRespMar 23 | ): VersionsRoute[F] = new VersionsRoute(versions) 24 | 25 | case class OcpiVersionConfig( 26 | endPoints: Map[EndpointIdentifier, Either[Url, GuardedRoute]] 27 | ) 28 | } 29 | 30 | class VersionsRoute[F[_]: Effect: HktMarshallable] private[ocpi]( 31 | versions: => F[Map[VersionNumber, OcpiVersionConfig]] 32 | )( 33 | implicit successRespListVerM: SuccessRespMar[List[Versions.Version]], 34 | successVerDetM: SuccessRespMar[VersionDetails], 35 | errorM: ErrRespMar 36 | ) extends OcpiDirectives { 37 | 38 | import VersionsRoute._ 39 | 40 | private val EndPointPathMatcher = Segment.map(EndpointIdentifier(_)) 41 | 42 | private def appendPath(uri: Uri, segments: String*) = 43 | uri.withPath(segments.foldLeft(uri.path) { 44 | case (path, add) => path ?/ add 45 | }) 46 | 47 | def versionsRoute( 48 | uri: Uri 49 | ): Route = onSuccess(Effect[F].toIO(versions).unsafeToFuture()) { 50 | case v if v.nonEmpty => 51 | complete( 52 | SuccessResp( 53 | GenericSuccess, 54 | None, 55 | ZonedDateTime.now, 56 | v.keys.map(x => Version(x, Url(appendPath(uri, x.toString).toString))).toList 57 | ) 58 | ) 59 | case _ => reject(NoVersionsRejection()) 60 | } 61 | 62 | def versionDetailsRoute( 63 | version: VersionNumber, 64 | versionInfo: OcpiVersionConfig, 65 | uri: Uri, 66 | apiUser: GlobalPartyId 67 | ): Route = 68 | pathEndOrSingleSlash { 69 | complete( 70 | SuccessResp( 71 | GenericSuccess, 72 | None, 73 | ZonedDateTime.now, 74 | VersionDetails( 75 | version, 76 | versionInfo.endPoints.map { 77 | case (k, Right(v)) => Endpoint(k, Url(appendPath(uri, k.value).toString)) 78 | case (k, Left(extUri)) => Endpoint(k, extUri) 79 | } 80 | ) 81 | ) 82 | ) 83 | } ~ 84 | pathPrefix(EndPointPathMatcher) { path => 85 | versionInfo.endPoints.get(path) match { 86 | case None => reject 87 | case Some(Left(_)) => reject // implemented externally 88 | case Some(Right(route)) => route(version, apiUser) 89 | } 90 | } 91 | 92 | private val VersionMatcher: PathMatcher1[ocpi.Version] = Segment.flatMap(s => VersionNumber.opt(s)) 93 | 94 | def apply( 95 | apiUser: GlobalPartyId, 96 | securedConnection: Boolean = true 97 | ): Route = { 98 | (handleRejections(VersionRejections.Handler) & handleExceptions(OcpiExceptionHandler.Default)) { 99 | extractUri { reqUri => 100 | val uri = reqUri.withScheme(Uri.httpScheme(securedConnection)) 101 | pathEndOrSingleSlash { 102 | versionsRoute(uri) 103 | } ~ 104 | pathPrefix(VersionMatcher) { version => 105 | onSuccess(Effect[F].toIO(versions).unsafeToFuture()) { vers => 106 | val route = for { 107 | supportedVersion <- vers.get(version) 108 | } yield versionDetailsRoute(version, supportedVersion, uri, apiUser) 109 | route.getOrElse(reject(UnsupportedVersionRejection(version))) 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /example/src/main/scala/com/thenewmotion/ocpi/example/ExampleApp.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package example 3 | 4 | import _root_.akka.actor.ActorSystem 5 | import _root_.akka.http.scaladsl.Http 6 | import _root_.akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ 7 | import _root_.akka.http.scaladsl.server.Directives._ 8 | import cats.effect.{ContextShift, IO} 9 | import com.thenewmotion.ocpi.VersionsRoute.OcpiVersionConfig 10 | import com.thenewmotion.ocpi.common.TokenAuthenticator 11 | import com.thenewmotion.ocpi.msgs.Ownership.Theirs 12 | import com.thenewmotion.ocpi.msgs.Versions.EndpointIdentifier._ 13 | import com.thenewmotion.ocpi.msgs.Versions.{Endpoint, VersionNumber} 14 | import com.thenewmotion.ocpi.msgs._ 15 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.protocol._ 16 | import com.thenewmotion.ocpi.msgs.v2_1.Credentials.Creds 17 | import com.thenewmotion.ocpi.registration.{RegistrationClient, RegistrationRepo, RegistrationRoute, RegistrationService} 18 | 19 | 20 | class ExampleRegistrationRepo extends RegistrationRepo[IO] { 21 | def isPartyRegistered(globalPartyId: GlobalPartyId): IO[Boolean] = ??? 22 | def findTheirAuthToken(globalPartyId: GlobalPartyId): IO[Option[AuthToken[Theirs]]] = ??? 23 | def persistInfoAfterConnectToUs( 24 | globalPartyId: GlobalPartyId, 25 | version: VersionNumber, 26 | newTokenToConnectToUs: AuthToken[Theirs], 27 | credsToConnectToThem: Creds[Theirs], 28 | endpoints: Iterable[Endpoint] 29 | ): IO[Unit] = ??? 30 | 31 | def persistInfoAfterConnectToThem( 32 | version: VersionNumber, 33 | newTokenToConnectToUs: AuthToken[Theirs], 34 | newCredToConnectToThem: Creds[Theirs], 35 | endpoints: Iterable[Endpoint] 36 | ): IO[Unit] = ??? 37 | 38 | def deletePartyInformation( 39 | globalPartyId: GlobalPartyId 40 | ): IO[Unit] = ??? 41 | } 42 | 43 | 44 | object ExampleApp extends App { 45 | implicit val system = ActorSystem() 46 | implicit val executor = system.dispatcher 47 | implicit val http = Http() 48 | implicit private val ctxShift: ContextShift[IO] = IO.contextShift(executor) 49 | 50 | val repo = new ExampleRegistrationRepo() 51 | 52 | val client = new RegistrationClient[IO]() 53 | 54 | val service = new RegistrationService( 55 | client, 56 | repo, 57 | ourGlobalPartyId = GlobalPartyId("nl", "exp"), 58 | ourPartyName = "Example", 59 | ourVersions = Set(VersionNumber.`2.1`), 60 | ourVersionsUrl = Url("www.ocpi-example.com/ocpi/versions")) 61 | 62 | import com.thenewmotion.ocpi.common.HktMarshallableInstances._ 63 | val registrationRoute = RegistrationRoute[IO](service) 64 | 65 | val versionRoute = VersionsRoute( 66 | IO.pure(Map(VersionNumber.`2.1` -> OcpiVersionConfig( 67 | Map( 68 | Credentials -> Right(registrationRoute.apply), 69 | Locations -> Left(Url("http://locations.ocpi-example.com")) 70 | ) 71 | ))) 72 | ) 73 | 74 | val auth = new TokenAuthenticator[IO](_ => IO.pure(Some(GlobalPartyId("NL", "TNM")))) 75 | 76 | val topLevelRoute = { 77 | pathPrefix("example") { 78 | authenticateOrRejectWithChallenge(auth) { user => 79 | pathPrefix("versions") { 80 | versionRoute(user) 81 | } 82 | } 83 | } 84 | } 85 | 86 | Http().newServerAt("localhost", 8080).bindFlow(topLevelRoute) 87 | 88 | } 89 | -------------------------------------------------------------------------------- /example/src/main/scala/com/thenewmotion/ocpi/example/ExampleCatsIO.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.example 2 | 3 | import java.time.ZonedDateTime 4 | import _root_.akka.actor.ActorSystem 5 | import _root_.akka.http.scaladsl.Http 6 | import _root_.akka.http.scaladsl.server.Directives._ 7 | import cats.effect.{ExitCode, IO, IOApp} 8 | import com.thenewmotion.ocpi.common.{Pager, PaginatedResult, TokenAuthenticator} 9 | import com.thenewmotion.ocpi.msgs._ 10 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens._ 11 | import com.thenewmotion.ocpi.tokens.{AuthorizeError, MspTokensRoute, MspTokensService} 12 | 13 | /** 14 | * Uses cats-effect's IO datatype 15 | */ 16 | object ExampleCatsIO extends IOApp { 17 | 18 | object IOBasedTokensService extends MspTokensService[IO] { 19 | def tokens( 20 | globalPartyId: GlobalPartyId, 21 | pager: Pager, 22 | dateFrom: Option[ZonedDateTime] = None, 23 | dateTo: Option[ZonedDateTime] = None 24 | ): IO[PaginatedResult[Token]] = IO.pure(PaginatedResult(Nil, 0)) 25 | 26 | def authorize( 27 | globalPartyId: GlobalPartyId, 28 | tokenUid: TokenUid, 29 | locationReferences: Option[LocationReferences] 30 | ): IO[Either[AuthorizeError, AuthorizationInfo]] = IO.pure(Right(AuthorizationInfo(Allowed.Allowed))) 31 | } 32 | 33 | implicit val system = ActorSystem() 34 | implicit val executor = system.dispatcher 35 | 36 | val service = IOBasedTokensService 37 | 38 | import com.thenewmotion.ocpi.common.HktMarshallableInstances._ 39 | import com.thenewmotion.ocpi.msgs.circe.v2_1.CommonJsonProtocol._ 40 | import com.thenewmotion.ocpi.msgs.circe.v2_1.TokensJsonProtocol._ 41 | import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._ 42 | val tokensRoute = MspTokensRoute(service) 43 | 44 | val auth = new TokenAuthenticator[IO](_ => IO.pure(Some(GlobalPartyId("NL", "TNM")))) 45 | 46 | val topLevelRoute = { 47 | pathPrefix("example") { 48 | authenticateOrRejectWithChallenge(auth) { user => 49 | pathPrefix("versions") { 50 | tokensRoute(user) 51 | } 52 | } 53 | } 54 | } 55 | 56 | 57 | 58 | override def run(args: List[String]): IO[ExitCode] = 59 | IO.fromFuture(IO(Http().newServerAt("localhost", 8080).bindFlow(topLevelRoute))).map(_ => ExitCode.Success) 60 | 61 | } 62 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CdrsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.Cdrs._ 5 | import io.circe.generic.extras.semiauto._ 6 | import io.circe.{Decoder, Encoder} 7 | import TariffsJsonProtocol._ 8 | import CommonJsonProtocol._ 9 | import LocationsJsonProtocol._ 10 | 11 | trait CdrsJsonProtocol { 12 | implicit val cdrIdE: Encoder[CdrId] = stringEncoder(_.value) 13 | implicit val cdrIdD: Decoder[CdrId] = tryStringDecoder(CdrId.apply) 14 | 15 | implicit val cdrDimensionE: Encoder[CdrDimension] = deriveConfiguredEncoder 16 | implicit val cdrDimensionD: Decoder[CdrDimension] = deriveConfiguredDecoder 17 | 18 | implicit val chargingPeriodE: Encoder[ChargingPeriod] = deriveConfiguredEncoder 19 | implicit val chargingPeriodD: Decoder[ChargingPeriod] = deriveConfiguredDecoder 20 | 21 | implicit val cdrE: Encoder[Cdr] = deriveConfiguredEncoder 22 | implicit val cdrD: Decoder[Cdr] = deriveConfiguredDecoder 23 | } 24 | 25 | object CdrsJsonProtocol extends CdrsJsonProtocol 26 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CommandsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.Commands.Command.UnlockConnector 5 | import v2_1.Commands.{Command, CommandResponse} 6 | import io.circe.generic.extras.semiauto._ 7 | import io.circe.{Decoder, Encoder} 8 | import CommonJsonProtocol._ 9 | import TokensJsonProtocol._ 10 | import LocationsJsonProtocol._ 11 | import SessionJsonProtocol._ 12 | 13 | trait CommandsJsonProtocol { 14 | implicit val commandResponseE: Encoder[CommandResponse] = deriveConfiguredEncoder 15 | implicit val commandResponseD: Decoder[CommandResponse] = deriveConfiguredDecoder 16 | 17 | implicit val reserveNowE: Encoder[Command.ReserveNow] = deriveConfiguredEncoder 18 | implicit val reserveNowD: Decoder[Command.ReserveNow] = deriveConfiguredDecoder 19 | 20 | implicit val startSessionE: Encoder[Command.StartSession] = deriveConfiguredEncoder 21 | implicit val startSessionD: Decoder[Command.StartSession] = deriveConfiguredDecoder 22 | 23 | implicit val stopSessionE: Encoder[Command.StopSession] = deriveConfiguredEncoder 24 | implicit val stopSessionD: Decoder[Command.StopSession] = deriveConfiguredDecoder 25 | 26 | implicit val unlockConnectorE: Encoder[UnlockConnector] = deriveConfiguredEncoder 27 | implicit val unlockConnectorD: Decoder[UnlockConnector] = deriveConfiguredDecoder 28 | } 29 | 30 | object CommandsJsonProtocol extends CommandsJsonProtocol 31 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CredentialsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.CommonTypes.BusinessDetails 5 | import v2_1.Credentials.Creds 6 | import io.circe.{Decoder, Encoder} 7 | import CommonJsonProtocol._ 8 | 9 | trait CredentialsJsonProtocol { 10 | 11 | implicit def credsE[O <: Ownership]: Encoder[Creds[O]] = 12 | Encoder.forProduct5("token", "url", "business_details", "country_code", "party_id")(c => 13 | (c.token, c.url, c.businessDetails, c.globalPartyId.countryCode, c.globalPartyId.partyId) 14 | ) 15 | 16 | implicit def credsD[O <: Ownership]: Decoder[Creds[O]] = 17 | Decoder.forProduct5("token", "url", "business_details", "country_code", "party_id") { 18 | (token: AuthToken[O#Opposite], url: Url, businessDetails: BusinessDetails, countryCode: String, partyId: String) => 19 | Creds[O](token, url, businessDetails, GlobalPartyId(countryCode, partyId)) 20 | } 21 | 22 | } 23 | 24 | object CredentialsJsonProtocol extends CredentialsJsonProtocol 25 | 26 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/LocationsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.Locations._ 5 | import io.circe.generic.extras.semiauto._ 6 | import io.circe.{Decoder, Encoder} 7 | import CommonJsonProtocol._ 8 | 9 | trait LocationsJsonProtocol { 10 | 11 | def strict: Boolean 12 | 13 | implicit val connectorIdE: Encoder[ConnectorId] = stringEncoder(_.value) 14 | implicit val connectorIdD: Decoder[ConnectorId] = tryStringDecoder(ConnectorId.apply) 15 | 16 | implicit val locationIdE: Encoder[LocationId] = stringEncoder(_.value) 17 | implicit val locationIdD: Decoder[LocationId] = tryStringDecoder(LocationId.apply) 18 | 19 | implicit val evseUidE: Encoder[EvseUid] = stringEncoder(_.value) 20 | implicit val evseUidD: Decoder[EvseUid] = tryStringDecoder(EvseUid.apply) 21 | 22 | implicit val energySourceE: Encoder[EnergySource] = deriveConfiguredEncoder 23 | implicit val energySourceD: Decoder[EnergySource] = deriveConfiguredDecoder 24 | 25 | implicit val environmentalImpactE: Encoder[EnvironmentalImpact] = deriveConfiguredEncoder 26 | implicit val environmentalImpactD: Decoder[EnvironmentalImpact] = deriveConfiguredDecoder 27 | 28 | implicit val energyMixE: Encoder[EnergyMix] = deriveConfiguredEncoder 29 | implicit val energyMixD: Decoder[EnergyMix] = deriveConfiguredDecoder 30 | 31 | implicit val latitudeE: Encoder[Latitude] = stringEncoder(_.toString) 32 | implicit val latitudeD: Decoder[Latitude] = 33 | if (strict) tryStringDecoder(Latitude.strict) else tryStringDecoder(Latitude.apply) 34 | 35 | implicit val longitudeE: Encoder[Longitude] = stringEncoder(_.toString) 36 | implicit val longitudeD: Decoder[Longitude] = 37 | if (strict) tryStringDecoder(Longitude.strict) else tryStringDecoder(Longitude.apply) 38 | 39 | implicit val geoLocationE: Encoder[GeoLocation] = deriveConfiguredEncoder 40 | implicit val geoLocationD: Decoder[GeoLocation] = deriveConfiguredDecoder 41 | 42 | implicit val additionalGeoLocationE: Encoder[AdditionalGeoLocation] = deriveConfiguredEncoder 43 | implicit val additionalGeoLocationD: Decoder[AdditionalGeoLocation] = deriveConfiguredDecoder 44 | 45 | implicit val regularHoursE: Encoder[RegularHours] = deriveConfiguredEncoder 46 | implicit val regularHoursD: Decoder[RegularHours] = deriveConfiguredDecoder 47 | 48 | implicit val exceptionalPeriodE: Encoder[ExceptionalPeriod] = deriveConfiguredEncoder 49 | implicit val exceptionalPeriodD: Decoder[ExceptionalPeriod] = deriveConfiguredDecoder 50 | 51 | implicit val hoursE: Encoder[Hours] = deriveConfiguredEncoder 52 | implicit val hoursD: Decoder[Hours] = deriveConfiguredDecoder 53 | 54 | implicit val connectorE: Encoder[Connector] = deriveConfiguredEncoder 55 | implicit val connectorD: Decoder[Connector] = deriveConfiguredDecoder 56 | 57 | implicit val connectorPatchE: Encoder[ConnectorPatch] = deriveConfiguredEncoder 58 | implicit val connectorPatchD: Decoder[ConnectorPatch] = deriveConfiguredDecoder 59 | 60 | implicit val statusScheduleE: Encoder[StatusSchedule] = deriveConfiguredEncoder 61 | implicit val statusScheduleD: Decoder[StatusSchedule] = deriveConfiguredDecoder 62 | 63 | implicit val evseE: Encoder[Evse] = deriveConfiguredEncoder 64 | implicit val evseD: Decoder[Evse] = deriveConfiguredDecoder 65 | 66 | implicit val evsePatchE: Encoder[EvsePatch] = deriveConfiguredEncoder 67 | implicit val evsePatchD: Decoder[EvsePatch] = deriveConfiguredDecoder 68 | 69 | implicit val locationE: Encoder[Location] = deriveConfiguredEncoder 70 | implicit val locationD: Decoder[Location] = deriveConfiguredDecoder 71 | 72 | implicit val locationPatchE: Encoder[LocationPatch] = deriveConfiguredEncoder 73 | implicit val locationPatchD: Decoder[LocationPatch] = deriveConfiguredDecoder 74 | } 75 | 76 | object LocationsJsonProtocol extends LocationsJsonProtocol { 77 | override def strict = true 78 | } 79 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/SessionJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.Sessions.{Session, SessionId, SessionPatch, SessionStatus} 5 | import io.circe.generic.extras.semiauto._ 6 | import io.circe.{Decoder, Encoder} 7 | import CommonJsonProtocol._ 8 | import TokensJsonProtocol._ 9 | import CdrsJsonProtocol._ 10 | import LocationsJsonProtocol._ 11 | 12 | trait SessionJsonProtocol { 13 | implicit val sessionIdE: Encoder[SessionId] = stringEncoder(_.value) 14 | implicit val sessionIdD: Decoder[SessionId] = tryStringDecoder(SessionId.apply) 15 | 16 | implicit val sessionE: Encoder[Session] = deriveConfiguredEncoder 17 | implicit val sessionD: Decoder[Session] = deriveConfiguredDecoder 18 | 19 | implicit val sessionPatchE: Encoder[SessionPatch] = deriveConfiguredEncoder 20 | implicit val sessionPatchD: Decoder[SessionPatch] = deriveConfiguredDecoder 21 | } 22 | 23 | object SessionJsonProtocol extends SessionJsonProtocol 24 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/TariffsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import v2_1.Tariffs._ 5 | import io.circe.generic.extras.semiauto._ 6 | import io.circe.{Decoder, Encoder} 7 | import CommonJsonProtocol._ 8 | import LocationsJsonProtocol._ 9 | 10 | trait TariffsJsonProtocol { 11 | 12 | implicit val tariffIdE: Encoder[TariffId] = stringEncoder(_.value) 13 | implicit val tariffIdD: Decoder[TariffId] = tryStringDecoder(TariffId.apply) 14 | 15 | implicit val priceComponentE: Encoder[PriceComponent] = deriveConfiguredEncoder 16 | implicit val priceComponentD: Decoder[PriceComponent] = deriveConfiguredDecoder 17 | 18 | implicit val tariffRestrictionsE: Encoder[TariffRestrictions] = deriveConfiguredEncoder 19 | implicit val tariffRestrictionsD: Decoder[TariffRestrictions] = deriveConfiguredDecoder 20 | 21 | implicit val tariffElementE: Encoder[TariffElement] = deriveConfiguredEncoder 22 | implicit val tariffElementD: Decoder[TariffElement] = deriveConfiguredDecoder 23 | 24 | implicit val tariffE: Encoder[Tariff] = deriveConfiguredEncoder 25 | implicit val tariffD: Decoder[Tariff] = deriveConfiguredDecoder 26 | } 27 | 28 | object TariffsJsonProtocol extends TariffsJsonProtocol 29 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/TokensJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens._ 4 | import io.circe.generic.extras.semiauto._ 5 | import io.circe.{Decoder, Encoder} 6 | import LocationsJsonProtocol._ 7 | import CommonJsonProtocol._ 8 | 9 | trait TokensJsonProtocol { 10 | implicit val tokenUidE: Encoder[TokenUid] = stringEncoder(_.value) 11 | implicit val tokenUidD: Decoder[TokenUid] = tryStringDecoder(TokenUid.apply) 12 | 13 | implicit val authIdE: Encoder[AuthId] = stringEncoder(_.value) 14 | implicit val authIdD: Decoder[AuthId] = tryStringDecoder(AuthId.apply) 15 | 16 | implicit val tokenE: Encoder[Token] = deriveConfiguredEncoder 17 | implicit val tokenD: Decoder[Token] = deriveConfiguredDecoder 18 | 19 | implicit val tokenPatchE: Encoder[TokenPatch] = deriveConfiguredEncoder 20 | implicit val tokenPatchD: Decoder[TokenPatch] = deriveConfiguredDecoder 21 | 22 | implicit val locationReferencesE: Encoder[LocationReferences] = deriveConfiguredEncoder 23 | implicit val locationReferencesD: Decoder[LocationReferences] = deriveConfiguredDecoder 24 | 25 | implicit val authorizationInfoE: Encoder[AuthorizationInfo] = deriveConfiguredEncoder 26 | implicit val authorizationInfoD: Decoder[AuthorizationInfo] = deriveConfiguredDecoder 27 | } 28 | 29 | object TokensJsonProtocol extends TokensJsonProtocol 30 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/VersionsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package circe.v2_1 3 | 4 | import Versions._ 5 | import io.circe.generic.extras.semiauto._ 6 | import io.circe.{Decoder, Encoder} 7 | import CommonJsonProtocol._ 8 | 9 | trait VersionsJsonProtocol { 10 | 11 | implicit val versionNumberE: Encoder[VersionNumber] = stringEncoder(_.toString) 12 | implicit val versionNumberD: Decoder[VersionNumber] = tryStringDecoder(VersionNumber.apply) 13 | 14 | implicit val versionE: Encoder[Version] = deriveConfiguredEncoder 15 | implicit val versionD: Decoder[Version] = deriveConfiguredDecoder 16 | 17 | implicit val endpointE: Encoder[Endpoint] = deriveConfiguredEncoder 18 | implicit val endpointD: Decoder[Endpoint] = deriveConfiguredDecoder 19 | 20 | implicit val versionDetailsE: Encoder[VersionDetails] = deriveConfiguredEncoder 21 | implicit val versionDetailsD: Decoder[VersionDetails] = deriveConfiguredDecoder 22 | } 23 | 24 | object VersionsJsonProtocol extends VersionsJsonProtocol 25 | -------------------------------------------------------------------------------- /msgs-circe/src/main/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe 2 | 3 | import io.circe.generic.extras.Configuration 4 | import io.circe.{Decoder, Encoder} 5 | import scala.util.Try 6 | 7 | package object v2_1 { 8 | implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames.withDefaults 9 | 10 | def stringEncoder[T](enc: T => String): Encoder[T] = 11 | Encoder.encodeString.contramap[T](enc) 12 | 13 | def tryStringDecoder[T](dec: String => T): Decoder[T] = 14 | Decoder.decodeString.flatMap { str => 15 | Decoder.instanceTry { _ => 16 | Try(dec(str)) 17 | } 18 | } 19 | 20 | object protocol 21 | extends CdrsJsonProtocol 22 | with CommandsJsonProtocol 23 | with CredentialsJsonProtocol 24 | with CommonJsonProtocol 25 | with LocationsJsonProtocol 26 | with SessionJsonProtocol 27 | with TariffsJsonProtocol 28 | with TokensJsonProtocol 29 | with VersionsJsonProtocol { 30 | override def strict: Boolean = true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CdrsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCdrsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class CdrsSpec extends GenericCdrsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import CdrsJsonProtocol._ 9 | 10 | "Circe Json CdrsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CirceJsonSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericJsonSpec 4 | import io.circe.{parser, _} 5 | import io.circe.syntax._ 6 | 7 | trait CirceJsonSpec extends GenericJsonSpec[Json, Decoder, Encoder] { 8 | 9 | override def parse(s: String): Json = parser.parse(s) match { 10 | case Left(ex: ParsingFailure) => throw ex 11 | case Right(x) => x 12 | } 13 | 14 | override def jsonStringToJson(s: String): Json = Json.fromString(s) 15 | 16 | override def jsonToObj[T : Decoder](j: Json): T = 17 | j.as[T] match { 18 | case Left(ex: DecodingFailure) => throw ex 19 | case Right(x) => x 20 | } 21 | 22 | override def objToJson[T : Encoder](t: T): Json = { 23 | // we can only remove the null keys when printing, so we print then re-parse, bit hacky but works 24 | val printer = Printer.noSpaces.copy(dropNullValues = true) 25 | parse(printer.print(t.asJson)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CommonTypesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCommonTypesSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class CommonTypesSpec extends GenericCommonTypesSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | import CommonJsonProtocol._ 8 | 9 | "Circe DefaultJsonProtocol" should { 10 | runTests(successPagedResp) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/CredentialsSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCredentialsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class CredentialsSpecs extends GenericCredentialsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import CredentialsJsonProtocol._ 9 | 10 | "Circe CredentialsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/LocationsSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericLocationsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class LocationsSpecs extends GenericLocationsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import LocationsJsonProtocol._ 9 | 10 | "Circe LocationsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/SessionsSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericSessionsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class SessionsSpecs extends GenericSessionsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import SessionJsonProtocol._ 9 | 10 | "Circe SessionsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/TariffsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericTariffsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class TariffsSpec extends GenericTariffsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import TariffsJsonProtocol._ 9 | 10 | "Circe TariffsJsonProtocol" should { 11 | runTests() 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/TokensSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericTokensSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class TokensSpec extends GenericTokensSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import TokensJsonProtocol._ 9 | 10 | "Circe TokenJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-circe/src/test/scala/com/thenewmotion/ocpi/msgs/circe/v2_1/VersionsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.circe.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericVersionsSpec 4 | import io.circe.{Decoder, Encoder, Json} 5 | 6 | class VersionsSpec extends GenericVersionsSpec[Json, Decoder, Encoder] with CirceJsonSpec { 7 | 8 | import VersionsJsonProtocol._ 9 | 10 | "Circe VersionsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-json-test/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/GenericCredentialsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.{AuthToken, GlobalPartyId, Url} 4 | import com.thenewmotion.ocpi.msgs.Ownership.{Ours, Theirs} 5 | import com.thenewmotion.ocpi.msgs.v2_1.CommonTypes._ 6 | import com.thenewmotion.ocpi.msgs.v2_1.Credentials._ 7 | import org.specs2.specification.core.Fragments 8 | 9 | trait GenericCredentialsSpec[J, GenericJsonReader[_], GenericJsonWriter[_]] extends 10 | GenericJsonSpec[J, GenericJsonReader, GenericJsonWriter] { 11 | 12 | def runTests()( 13 | implicit credsR: GenericJsonReader[Creds[Ours]], 14 | credsW: GenericJsonWriter[Creds[Ours]] 15 | ): Fragments = { 16 | "Creds" should { 17 | testPair(credentials1, parse(credentialsJson1)) 18 | } 19 | } 20 | 21 | val businessDetails1 = BusinessDetails( 22 | "Example Operator", 23 | Some(Image(Url("http://example.com/images/logo.png"), ImageCategory.Operator, "png")), 24 | Some(Url("http://example.com")) 25 | ) 26 | val credentials1 = Creds[Ours]( 27 | token = AuthToken[Theirs]("ebf3b399-779f-4497-9b9d-ac6ad3cc44d2"), 28 | url = Url("https://example.com/ocpi/cpo/"), 29 | businessDetails = businessDetails1, 30 | globalPartyId = GlobalPartyId("NL", "EXA") 31 | ) 32 | 33 | val logo1 = businessDetails1.logo.get 34 | val credentialsJson1 = 35 | s""" 36 | |{ 37 | | "token": "${credentials1.token.value}", 38 | | "url": "${credentials1.url}", 39 | | "business_details": { 40 | | "name": "${credentials1.businessDetails.name}", 41 | | "logo": { 42 | | "url": "${logo1.url}", 43 | | "category": "${logo1.category.name}", 44 | | "type": "${logo1.`type`}" 45 | | }, 46 | | "website": "${credentials1.businessDetails.website.get}" 47 | | }, 48 | | "party_id": "${credentials1.globalPartyId.partyId}", 49 | | "country_code": "${credentials1.globalPartyId.countryCode}" 50 | |} 51 | """.stripMargin 52 | } 53 | -------------------------------------------------------------------------------- /msgs-json-test/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/GenericJsonSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import org.specs2.mutable.SpecificationWithJUnit 4 | import org.specs2.specification.core.Fragments 5 | 6 | 7 | trait GenericJsonSpec[J, GenericJsonReader[_], GenericJsonWriter[_]] extends SpecificationWithJUnit { 8 | def parse(s: String): J 9 | 10 | def jsonStringToJson(s: String): J 11 | def jsonToObj[T : GenericJsonReader](j: J): T 12 | def objToJson[T : GenericJsonWriter](t: T): J 13 | 14 | def parseAs[T : GenericJsonReader](s: String): T = jsonToObj(parse(s)) 15 | 16 | def testPair[T : GenericJsonWriter : GenericJsonReader](obj: T, json: J): Fragments = { 17 | "serialize" in { 18 | objToJson(obj) === json 19 | } 20 | "deserialize" in { 21 | jsonToObj(json) === obj 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /msgs-json-test/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/GenericTokensSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.LocationReferences 4 | import org.specs2.specification.Scope 5 | import org.specs2.specification.core.Fragments 6 | 7 | trait GenericTokensSpec[J, GenericJsonReader[_], GenericJsonWriter[_]] extends 8 | GenericJsonSpec[J, GenericJsonReader, GenericJsonWriter] { 9 | 10 | def runTests()( 11 | implicit locRefD: GenericJsonReader[LocationReferences] 12 | ): Fragments = { 13 | "LocationReferences" should { 14 | "deserialize missing fields of cardinality '*' to empty lists" in new Scope { 15 | val locRefs: LocationReferences = 16 | parseAs[LocationReferences](""" 17 | | { 18 | | "location_id": "loc1" 19 | | } 20 | """.stripMargin) 21 | 22 | locRefs.evseUids mustEqual Nil 23 | locRefs.connectorIds mustEqual Nil 24 | } 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /msgs-json-test/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/GenericVersionsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import com.thenewmotion.ocpi.ZonedDateTimeParser 4 | import com.thenewmotion.ocpi.msgs.Url 5 | import com.thenewmotion.ocpi.msgs.Versions.VersionNumber._ 6 | import com.thenewmotion.ocpi.msgs.Versions._ 7 | import org.specs2.specification.core.Fragments 8 | 9 | trait GenericVersionsSpec[J, GenericJsonReader[_], GenericJsonWriter[_]] extends 10 | GenericJsonSpec[J, GenericJsonReader, GenericJsonWriter] { 11 | 12 | def runTests()( 13 | implicit versionR: GenericJsonReader[List[Version]], 14 | versionW: GenericJsonWriter[List[Version]], 15 | versionDetailsR: GenericJsonReader[VersionDetails], 16 | versionDetailsW: GenericJsonWriter[VersionDetails] 17 | ): Fragments = { 18 | 19 | "VersionsResp" should { 20 | testPair(versionResp, parse(versionRespJson1)) 21 | } 22 | 23 | "VersionDetailsResp" should { 24 | testPair(version21Details, parse(version21DetailsRespJson)) 25 | } 26 | } 27 | 28 | val date1 = ZonedDateTimeParser.parse("2010-01-01T00:00:00Z") 29 | 30 | val version20 = Version( 31 | `2.0`, Url("https://example.com/ocpi/cpo/2.0/") 32 | ) 33 | val version21 = Version( 34 | `2.1`, Url("https://example.com/ocpi/cpo/2.1/") 35 | ) 36 | 37 | val versionResp = List(version20, version21) 38 | 39 | val credentialsEndpoint = Endpoint( 40 | EndpointIdentifier.Credentials, 41 | Url("https://example.com/ocpi/cpo/2.0/credentials/")) 42 | 43 | val locationsEndpoint = Endpoint( 44 | EndpointIdentifier.Locations, 45 | Url("https://example.com/ocpi/cpo/2.0/locations/")) 46 | 47 | val version21Details = VersionDetails( 48 | version = `2.1`, 49 | endpoints = List(credentialsEndpoint, locationsEndpoint) 50 | ) 51 | 52 | val versionRespJson1 = 53 | s""" 54 | | [ 55 | | { 56 | | "version": "2.0", 57 | | "url": "https://example.com/ocpi/cpo/2.0/" 58 | | }, 59 | | { 60 | | "version": "2.1", 61 | | "url": "https://example.com/ocpi/cpo/2.1/" 62 | | } 63 | | ] 64 | """.stripMargin 65 | 66 | val version21DetailsRespJson = 67 | s""" 68 | | { 69 | | "version": "2.1", 70 | | "endpoints": [ 71 | | { 72 | | "identifier": "credentials", 73 | | "url": "https://example.com/ocpi/cpo/2.0/credentials/" 74 | | }, 75 | | { 76 | | "identifier": "locations", 77 | | "url": "https://example.com/ocpi/cpo/2.0/locations/" 78 | | } 79 | | ] 80 | | } 81 | """.stripMargin 82 | 83 | lazy val version20DetailsIncompleteRespJson = 84 | s""" 85 | | { 86 | | "version": "2.0", 87 | | "endpoints": [ 88 | | { 89 | | "identifier": "locations", 90 | | "url": "https://example.com/ocpi/cpo/2.0/locations/" 91 | | } 92 | | ] 93 | | } 94 | """.stripMargin 95 | 96 | } 97 | -------------------------------------------------------------------------------- /msgs-shapeless/src/main/scala/com/thenewmotion/ocpi/msgs/shapeless/MergePatch.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.shapeless 2 | 3 | import com.thenewmotion.ocpi.msgs.{Resource, ResourceType} 4 | import com.thenewmotion.ocpi.msgs.ResourceType.{Full, Patch} 5 | import scala.annotation.implicitNotFound 6 | import shapeless._ 7 | import shapeless.ops.hlist.{IsHCons, Mapper, Zip} 8 | 9 | @implicitNotFound("It is not possible to merge ${P} into ${F}") 10 | trait ResourceMerge[F, P] { 11 | def apply(t: F, u: P): F 12 | } 13 | 14 | object ResourceMerge { 15 | 16 | protected object tupleMerger extends Poly1 { 17 | implicit def atTuple[A] = at[(A, A)] { 18 | case (b, _) => b 19 | } 20 | 21 | implicit def atOptRightTuple[A] = at[(A, Option[A])] { 22 | case (_, Some(a)) => a 23 | case (o, None) => o 24 | } 25 | 26 | implicit def atOptBothTuple[A] = at[(Option[A], Option[A])] { 27 | case (_, b @ Some(_)) => b 28 | case (o, None) => o 29 | } 30 | } 31 | 32 | implicit def mergeAnything[ 33 | F <: Resource[Full], 34 | P <: Resource[Patch], 35 | PA <: HList, 36 | FA <: HList, 37 | FAH, 38 | FAT <: HList, 39 | Z <: HList 40 | ]( 41 | implicit 42 | fAux: Generic.Aux[F, FA], 43 | pAux: Generic.Aux[P, PA], 44 | evHead: IsHCons.Aux[FA, FAH, FAT], 45 | zipper: Zip.Aux[FA :: (FAH :: PA) :: HNil, Z], 46 | mapper: Mapper.Aux[tupleMerger.type, Z, FA] 47 | ): ResourceMerge[F, P] = new ResourceMerge[F, P] { 48 | override def apply(t: F, patch: P) = { 49 | val reprFull: FA = fAux.to(t) 50 | val reprPatch: (FAH :: PA) = reprFull.head :: pAux.to(patch) 51 | 52 | val zipped: Z = reprFull.zip(reprPatch) 53 | val merged: FA = zipped.map(tupleMerger) 54 | 55 | fAux.from(merged) 56 | } 57 | } 58 | } 59 | 60 | object mergeSyntax { 61 | implicit class MergeSyntax[F <: Resource[Full], B[RT <: ResourceType] >: Resource[RT]](t: F) { 62 | def merge[P <: B[Patch]](patch: P)(implicit merge: ResourceMerge[F, P]): F = merge(t, patch) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /msgs-shapeless/src/test/scala/com/thenewmotion/ocpi/msgs/shapeless/MergePatchSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.shapeless 2 | 3 | import java.time.{ZoneOffset, ZonedDateTime} 4 | import java.time.format.DateTimeFormatter 5 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.AuthMethod 6 | import com.thenewmotion.ocpi.msgs.{CountryCode, CurrencyCode} 7 | import com.thenewmotion.ocpi.msgs.v2_1.Locations._ 8 | import com.thenewmotion.ocpi.msgs.v2_1.Sessions.{Session, SessionId, SessionPatch, SessionStatus} 9 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens._ 10 | import org.specs2.mutable.Specification 11 | import mergeSyntax._ 12 | import org.specs2.specification.Scope 13 | 14 | class MergePatchSpec extends Specification { 15 | 16 | "MergePatch" should { 17 | "merge a patch into a token" in new TokenScope { 18 | val patch = TokenPatch(valid = Some(false)) 19 | 20 | token.merge(patch) mustEqual 21 | Token( 22 | uid = TokenUid("23455655A"), 23 | `type` = TokenType.Rfid, 24 | authId = AuthId("NL-TNM-000660755-V"), 25 | visualNumber = Some("NL-TNM-066075-5"), 26 | issuer = "TheNewMotion", 27 | valid = false, 28 | whitelist = WhitelistType.Allowed, 29 | lastUpdated = ZonedDateTime.parse("2017-01-24T10:00:00.000Z") 30 | ) 31 | } 32 | 33 | "merge a patch into a session" in new SessionScope { 34 | val patch = SessionPatch(currency = Some(CurrencyCode("GBP"))) 35 | 36 | session1.merge(patch) mustEqual 37 | session1.copy( 38 | currency = CurrencyCode("GBP") 39 | ) 40 | } 41 | } 42 | 43 | trait TokenScope extends Scope { 44 | val token = Token( 45 | uid = TokenUid("23455655A"), 46 | `type` = TokenType.Rfid, 47 | authId = AuthId("NL-TNM-000660755-V"), 48 | visualNumber = Some("NL-TNM-066075-5"), 49 | issuer = "TheNewMotion", 50 | valid = true, 51 | whitelist = WhitelistType.Allowed, 52 | lastUpdated = ZonedDateTime.parse("2017-01-24T10:00:00.000Z") 53 | ) 54 | } 55 | 56 | trait SessionScope extends Scope { 57 | 58 | private def parseToUtc(s: String) = 59 | ZonedDateTime.parse(s, DateTimeFormatter.ISO_OFFSET_DATE_TIME).withZoneSameInstant(ZoneOffset.UTC) 60 | 61 | private val dateOfUpdate = parseToUtc("2016-12-31T23:59:59Z") 62 | 63 | val connector1 = Connector( 64 | ConnectorId("1"), 65 | lastUpdated = dateOfUpdate, 66 | ConnectorType.`IEC_62196_T2`, 67 | ConnectorFormat.Cable, 68 | PowerType.AC3Phase, 69 | 230, 70 | 16, 71 | tariffId = Some("kwrate") 72 | ) 73 | 74 | val evse1 = Evse( 75 | EvseUid("BE-BEC-E041503001"), 76 | lastUpdated = dateOfUpdate, 77 | ConnectorStatus.Available, 78 | capabilities = List(Capability.Reservable), 79 | connectors = List(connector1), 80 | floorLevel = Some("-1"), 81 | physicalReference = Some("1") 82 | ) 83 | 84 | val location1 = Location( 85 | LocationId("LOC1"), 86 | lastUpdated = dateOfUpdate, 87 | `type` = LocationType.OnStreet, 88 | Some("Gent Zuid"), 89 | address = "F.Rooseveltlaan 3A", 90 | city = "Gent", 91 | postalCode = "9000", 92 | country = CountryCode("BEL"), 93 | coordinates = GeoLocation(Latitude("3.729945"), Longitude("51.047594")), 94 | evses = List(evse1), 95 | directions = List.empty, 96 | operator = None, 97 | suboperator = None, 98 | openingTimes = None, 99 | relatedLocations = List.empty, 100 | chargingWhenClosed = Some(true), 101 | images = List.empty, 102 | energyMix = Some(EnergyMix( 103 | isGreenEnergy = true, 104 | energySources = Nil, 105 | environImpact = Nil, 106 | Some("Greenpeace Energy eG"), 107 | Some("eco-power") 108 | )) 109 | ) 110 | 111 | val session1 = Session( 112 | id = SessionId("abc"), 113 | startDatetime = parseToUtc("2017-03-01T08:00:00Z"), 114 | endDatetime = Some(parseToUtc("2017-03-01T10:00:00Z")), 115 | kwh = 1000.0, 116 | authId = AuthId("ABC1234"), 117 | authMethod = AuthMethod.AuthRequest, 118 | location = location1, 119 | meterId = None, 120 | currency = CurrencyCode("EUR"), 121 | chargingPeriods = Nil, 122 | totalCost = Some(10.24), 123 | status = SessionStatus.Completed, 124 | lastUpdated = dateOfUpdate 125 | ) 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/SimpleStringEnumSerializer.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson 2 | 3 | import com.thenewmotion.ocpi.{Enumerable, Nameable} 4 | import spray.json._ 5 | 6 | trait SimpleStringEnumSerializer { 7 | implicit def nameableFormat[T <: Nameable: Enumerable]: JsonFormat[T] = new JsonFormat[T] { 8 | def write(x: T) = JsString(x.name) 9 | def read(value: JsValue) = value match { 10 | case JsString(x) => 11 | implicitly[Enumerable[T]] 12 | .withName(x) 13 | .getOrElse( 14 | serializationError( 15 | s"Unknown " + 16 | s"value: $x" 17 | ) 18 | ) 19 | case x => deserializationError("Expected value as JsString, but got " + x) 20 | } 21 | } 22 | } 23 | 24 | object SimpleStringEnumSerializer extends SimpleStringEnumSerializer 25 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CdrsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package sprayjson.v2_1 3 | 4 | import spray.json.{JsString, JsValue, JsonFormat, deserializationError} 5 | import DefaultJsonProtocol._ 6 | import LocationsJsonProtocol._ 7 | import TariffsJsonProtocol._ 8 | import sprayjson.SimpleStringEnumSerializer._ 9 | import v2_1.Cdrs._ 10 | 11 | trait CdrsJsonProtocol { 12 | implicit val cdrIdFmt = new JsonFormat[CdrId] { 13 | override def read(json: JsValue) = json match { 14 | case JsString(s) => CdrId(s) 15 | case _ => deserializationError("CdrId must be a string") 16 | } 17 | override def write(obj: CdrId) = JsString(obj.value) 18 | } 19 | 20 | implicit val cdrDimensionFormat = jsonFormat2(CdrDimension) 21 | 22 | implicit val chargingPeriodFormat = jsonFormat2(ChargingPeriod) 23 | 24 | implicit val cdrFormat = jsonFormat16(Cdr) 25 | } 26 | 27 | object CdrsJsonProtocol extends CdrsJsonProtocol 28 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CommandsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package sprayjson.v2_1 3 | 4 | import v2_1.Commands.{Command, CommandResponse, CommandResponseType} 5 | import DefaultJsonProtocol._ 6 | import SessionJsonProtocol._ 7 | import TokensJsonProtocol._ 8 | import LocationsJsonProtocol._ 9 | import sprayjson.SimpleStringEnumSerializer._ 10 | 11 | trait CommandsJsonProtocol { 12 | 13 | implicit val commandResponse = jsonFormat1(CommandResponse) 14 | implicit val commandResponseType = nameableFormat[CommandResponseType] 15 | 16 | implicit val reserveNowF = jsonFormat6(Command.ReserveNow.apply) 17 | implicit val startSessionF = jsonFormat4(Command.StartSession.apply) 18 | implicit val stopSessionF = jsonFormat2(Command.StopSession.apply) 19 | implicit val unlockConnectorF = jsonFormat4(Command.UnlockConnector.apply) 20 | } 21 | 22 | object CommandsJsonProtocol extends CommandsJsonProtocol 23 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CredentialsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package sprayjson.v2_1 3 | 4 | import v2_1.CommonTypes.BusinessDetails 5 | import v2_1.Credentials.Creds 6 | import DefaultJsonProtocol._ 7 | import spray.json.{JsValue, RootJsonFormat} 8 | 9 | trait CredentialsJsonProtocol { 10 | 11 | implicit def credentialsFormat[O <: Ownership] = new RootJsonFormat[Creds[O]] { 12 | 13 | private case class InternalCreds( 14 | token: AuthToken[O#Opposite], 15 | url: Url, 16 | businessDetails: BusinessDetails, 17 | countryCode: String, 18 | partyId: String 19 | ) 20 | 21 | private implicit val intF = jsonFormat5(InternalCreds) 22 | 23 | override def write(obj: Creds[O]) = 24 | intF.write( 25 | InternalCreds( 26 | obj.token, 27 | obj.url, 28 | obj.businessDetails, 29 | obj.globalPartyId.countryCode, 30 | obj.globalPartyId.partyId 31 | ) 32 | ) 33 | 34 | override def read(json: JsValue) = { 35 | intF.read(json) match { 36 | case InternalCreds(t, u, bd, c, p) => 37 | Creds[O](t, u, bd, GlobalPartyId(c, p)) 38 | } 39 | } 40 | } 41 | } 42 | 43 | object CredentialsJsonProtocol extends CredentialsJsonProtocol 44 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/SessionJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package sprayjson.v2_1 3 | 4 | import java.time.ZonedDateTime 5 | import v2_1.Sessions.{Session, SessionId, SessionPatch, SessionStatus} 6 | import CdrsJsonProtocol._ 7 | import DefaultJsonProtocol._ 8 | import LocationsJsonProtocol._ 9 | import TokensJsonProtocol._ 10 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.{AuthMethod, ChargingPeriod} 11 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.Location 12 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.AuthId 13 | import sprayjson.SimpleStringEnumSerializer._ 14 | import spray.json.{JsString, JsValue, JsonFormat, RootJsonFormat, deserializationError} 15 | 16 | trait SessionJsonProtocol { 17 | 18 | implicit val sessionIdFmt = new JsonFormat[SessionId] { 19 | override def read(json: JsValue) = json match { 20 | case JsString(s) => SessionId(s) 21 | case _ => deserializationError("SessionId must be a string") 22 | } 23 | override def write(obj: SessionId) = JsString(obj.value) 24 | } 25 | 26 | private def deserializeSession( 27 | id: SessionId, 28 | startDatetime: ZonedDateTime, 29 | endDatetime: Option[ZonedDateTime], 30 | kwh: Int, 31 | authId: AuthId, 32 | authMethod: AuthMethod, 33 | location: Location, 34 | meterId: Option[String], 35 | currency: CurrencyCode, 36 | chargingPeriods: Option[Seq[ChargingPeriod]], 37 | totalCost: Option[BigDecimal], 38 | status: SessionStatus, 39 | lastUpdated: ZonedDateTime 40 | ): Session = 41 | Session( 42 | id, 43 | startDatetime, 44 | endDatetime, 45 | kwh, 46 | authId, 47 | authMethod, 48 | location, 49 | meterId, 50 | currency, 51 | chargingPeriods.getOrElse(Nil), 52 | totalCost, 53 | status, 54 | lastUpdated 55 | ) 56 | 57 | implicit val sessionFmt = new RootJsonFormat[Session] { 58 | val readFormat = jsonFormat13(deserializeSession) 59 | val writeFormat = jsonFormat13(Session.apply) 60 | override def read(json: JsValue) = readFormat.read(json) 61 | override def write(obj: Session): JsValue = writeFormat.write(obj) 62 | } 63 | 64 | implicit val sessionPatchFmt = jsonFormat12(SessionPatch.apply) 65 | } 66 | 67 | object SessionJsonProtocol extends SessionJsonProtocol 68 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/TariffsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Tariffs._ 4 | import DefaultJsonProtocol._ 5 | import LocationsJsonProtocol._ 6 | import com.thenewmotion.ocpi.msgs.sprayjson.SimpleStringEnumSerializer._ 7 | import spray.json.{JsString, JsValue, JsonFormat, deserializationError} 8 | 9 | trait TariffsJsonProtocol { 10 | implicit val tariffIdFmt = new JsonFormat[TariffId] { 11 | override def read(json: JsValue) = json match { 12 | case JsString(s) => TariffId(s) 13 | case _ => deserializationError("TariffId must be a string") 14 | } 15 | override def write(obj: TariffId) = JsString(obj.value) 16 | } 17 | 18 | implicit val priceComponentFormat = jsonFormat3(PriceComponent) 19 | 20 | implicit val tariffRestrictionsFormat = jsonFormat11(TariffRestrictions) 21 | 22 | implicit val tariffElementFormat = jsonFormat2(TariffElement) 23 | 24 | implicit val tariffFormat = jsonFormat7(Tariff) 25 | } 26 | 27 | object TariffsJsonProtocol extends TariffsJsonProtocol 28 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/TokensJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package sprayjson.v2_1 3 | 4 | import v2_1.Tokens._ 5 | import DefaultJsonProtocol._ 6 | import LocationsJsonProtocol._ 7 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.{ConnectorId, EvseUid, LocationId} 8 | import sprayjson.SimpleStringEnumSerializer._ 9 | import spray.json.{JsString, JsValue, JsonFormat, RootJsonFormat, deserializationError} 10 | 11 | trait TokensJsonProtocol { 12 | 13 | private def deserializeLocationReferences( 14 | locationId: LocationId, 15 | evseUids: Option[Iterable[EvseUid]], 16 | connectorIds: Option[Iterable[ConnectorId]] 17 | ) = LocationReferences( 18 | locationId, 19 | evseUids.getOrElse(Nil), 20 | connectorIds.getOrElse(Nil) 21 | ) 22 | 23 | implicit val tokenUidFmt = new JsonFormat[TokenUid] { 24 | override def read(json: JsValue) = json match { 25 | case JsString(s) => TokenUid(s) 26 | case _ => deserializationError("TokenUid must be a string") 27 | } 28 | override def write(obj: TokenUid) = JsString(obj.value) 29 | } 30 | 31 | implicit val authIdFmt = new JsonFormat[AuthId] { 32 | override def read(json: JsValue) = json match { 33 | case JsString(s) => AuthId(s) 34 | case _ => deserializationError("AuthId must be a string") 35 | } 36 | override def write(obj: AuthId) = JsString(obj.value) 37 | } 38 | 39 | implicit val tokensFormat = jsonFormat9(Token) 40 | 41 | implicit val tokenPatchFormat = jsonFormat8(TokenPatch) 42 | 43 | implicit val locationReferencesFormat = new RootJsonFormat[LocationReferences] { 44 | val readFormat = jsonFormat3(deserializeLocationReferences) 45 | val writeFormat = jsonFormat3(LocationReferences.apply) 46 | override def read(json: JsValue) = readFormat.read(json) 47 | override def write(obj: LocationReferences): JsValue = writeFormat.write(obj) 48 | } 49 | 50 | implicit val authorizationInfoFormat = jsonFormat3(AuthorizationInfo) 51 | } 52 | 53 | object TokensJsonProtocol extends TokensJsonProtocol 54 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/VersionsJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.Versions 4 | import spray.json.{JsString, JsValue, JsonFormat, deserializationError} 5 | import Versions._ 6 | import DefaultJsonProtocol._ 7 | 8 | trait VersionsJsonProtocol { 9 | 10 | implicit val endpointIdentifierFormat = new JsonFormat[EndpointIdentifier] { 11 | override def write(obj: EndpointIdentifier) = JsString(obj.value) 12 | 13 | override def read(json: JsValue) = json match { 14 | case JsString(s) => EndpointIdentifier(s) 15 | case x => deserializationError(s"Expected EndpointIdentifier as JsString, but got $x") 16 | } 17 | } 18 | 19 | implicit val versionNumberFormat = new JsonFormat[VersionNumber] { 20 | override def write(obj: VersionNumber) = JsString(obj.toString) 21 | 22 | override def read(json: JsValue) = json match { 23 | case JsString(s) => VersionNumber(s) 24 | case x => deserializationError(s"Expected VersionNumber as JsString, but got $x") 25 | } 26 | } 27 | 28 | implicit val versionFormat = jsonFormat2(Version) 29 | implicit val endpointFormat = jsonFormat2(Endpoint) 30 | implicit val versionDetailsFormat = jsonFormat2(VersionDetails) 31 | } 32 | 33 | object VersionsJsonProtocol extends VersionsJsonProtocol 34 | -------------------------------------------------------------------------------- /msgs-spray-json/src/main/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Id 4 | import spray.json.{JsValue, JsonFormat} 5 | 6 | package object v2_1 { 7 | 8 | implicit def idFmt[T : JsonFormat]: JsonFormat[Id[T]] = new JsonFormat[Id[T]] { 9 | override def read(json: JsValue) = implicitly[JsonFormat[T]].read(json) 10 | override def write(obj: Id[T]) = implicitly[JsonFormat[T]].write(obj) 11 | } 12 | 13 | object protocol 14 | extends CdrsJsonProtocol 15 | with CommandsJsonProtocol 16 | with CredentialsJsonProtocol 17 | with DefaultJsonProtocol 18 | with LocationsJsonProtocol 19 | with SessionJsonProtocol 20 | with TariffsJsonProtocol 21 | with TokensJsonProtocol 22 | with VersionsJsonProtocol { 23 | override def strict: Boolean = true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CdrsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCdrsSpec 4 | import spray.json._ 5 | 6 | class CdrsSpec extends GenericCdrsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import CdrsJsonProtocol._ 9 | 10 | "Spray Json CdrsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CommonTypesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCommonTypesSpec 4 | import com.thenewmotion.ocpi.msgs.sprayjson.v2_1.DefaultJsonProtocol 5 | import spray.json._ 6 | 7 | class CommonTypesSpec extends GenericCommonTypesSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 8 | 9 | import DefaultJsonProtocol._ 10 | 11 | "Spray DefaultJsonProtocol" should { 12 | runTests(successPagedRes) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/CredentialsSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericCredentialsSpec 4 | import spray.json._ 5 | 6 | class CredentialsSpecs extends GenericCredentialsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import CredentialsJsonProtocol._ 9 | 10 | "Spray CredentialsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/LocationsSpecs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericLocationsSpec 4 | import spray.json._ 5 | 6 | class LocationsSpecs extends GenericLocationsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import LocationsJsonProtocol._ 9 | 10 | "Spray Json LocationsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/SessionsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericSessionsSpec 4 | import spray.json._ 5 | 6 | class SessionsSpec extends GenericSessionsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import SessionJsonProtocol._ 9 | 10 | "Spray Json SessionsJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/SprayJsonSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericJsonSpec 4 | import spray.json.{JsValue, _} 5 | 6 | trait SprayJsonSpec extends GenericJsonSpec[JsValue, JsonReader, JsonWriter] { 7 | 8 | override def parse(s: String): JsValue = s.parseJson 9 | 10 | override def jsonStringToJson(s: String): JsValue = JsString(s) 11 | 12 | override def jsonToObj[T : JsonReader](j: JsValue): T = j.convertTo[T] 13 | 14 | override def objToJson[T : JsonWriter](t: T): JsValue = t.toJson 15 | } 16 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/TariffsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericTariffsSpec 4 | import spray.json._ 5 | 6 | class TariffsSpec extends GenericTariffsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import TariffsJsonProtocol._ 9 | 10 | "Spray Json TariffsJsonProtocol" should { 11 | runTests() 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/TokensSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericTokensSpec 4 | import spray.json._ 5 | 6 | class TokensSpec extends GenericTokensSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import TokensJsonProtocol._ 9 | 10 | "Spray Json TokenJsonProtocol" should { 11 | runTests() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /msgs-spray-json/src/test/scala/com/thenewmotion/ocpi/msgs/sprayjson/v2_1/VersionsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.sprayjson.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.GenericVersionsSpec 4 | import spray.json._ 5 | 6 | class VersionsSpec extends GenericVersionsSpec[JsValue, JsonReader, JsonWriter] with SprayJsonSpec { 7 | 8 | import DefaultJsonProtocol._ 9 | import VersionsJsonProtocol._ 10 | 11 | "Spray Json VersionsJsonProtocol" should { 12 | runTests() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/dateTimeParsing.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import java.time.{LocalDate, LocalTime, ZoneOffset, ZonedDateTime} 4 | import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder} 5 | import DateTimeFormatter._ 6 | import java.time.temporal.ChronoField._ 7 | 8 | import scala.util.Try 9 | 10 | object ZonedDateTimeParser { 11 | private val formatter = 12 | new DateTimeFormatterBuilder() 13 | .append(ISO_LOCAL_DATE_TIME) 14 | .optionalStart 15 | .appendLiteral('Z') 16 | .optionalEnd 17 | .toFormatter 18 | .withZone(ZoneOffset.UTC) 19 | 20 | def format(dt: ZonedDateTime): String = formatter.format(dt) 21 | 22 | def parseOpt(dt: String): Option[ZonedDateTime] = 23 | Try(ZonedDateTime.parse(dt, formatter)).toOption 24 | 25 | def parse(dt: String): ZonedDateTime = 26 | parseOpt(dt).getOrElse( 27 | throw new IllegalArgumentException("Expected DateTime conforming to pattern " + 28 | "specified in OCPI 21.2 section 14.2, but got " + dt) 29 | ) 30 | 31 | } 32 | 33 | object LocalTimeParser { 34 | private val formatter: DateTimeFormatter = 35 | new DateTimeFormatterBuilder() 36 | .parseLenient() 37 | .appendValue(HOUR_OF_DAY, 2) 38 | .appendLiteral(':') 39 | .appendValue(MINUTE_OF_HOUR, 2) 40 | .toFormatter 41 | 42 | def format(dt: LocalTime): String = formatter.format(dt) 43 | 44 | def parseOpt(dt: String): Option[LocalTime] = 45 | Try(LocalTime.parse(dt, formatter)).toOption 46 | 47 | def parse(dt: String): LocalTime = 48 | parseOpt(dt).getOrElse( 49 | throw new IllegalArgumentException(s"Expected LocalTime conforming to HH:mm, but got $dt") 50 | ) 51 | } 52 | 53 | object LocalDateParser { 54 | val formatter: DateTimeFormatter = 55 | new DateTimeFormatterBuilder() 56 | .appendValue(YEAR, 4) 57 | .appendLiteral('-') 58 | .appendValue(MONTH_OF_YEAR, 2) 59 | .appendLiteral('-') 60 | .appendValue(DAY_OF_MONTH, 2) 61 | .toFormatter 62 | 63 | 64 | def format(dt: LocalDate): String = formatter.format(dt) 65 | 66 | def parseOpt(dt: String): Option[LocalDate] = 67 | Try(LocalDate.parse(dt, formatter)).toOption 68 | 69 | def parse(dt: String): LocalDate = 70 | parseOpt(dt).getOrElse( 71 | throw new IllegalArgumentException(s"Expected LocalDate conforming to yyyy-MM-dd but got $dt") 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/GlobalPartyId.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import java.util.Locale 4 | 5 | trait GlobalPartyId { 6 | def countryCode: String 7 | def partyId: String 8 | 9 | override def toString = countryCode + partyId 10 | } 11 | 12 | object GlobalPartyId { 13 | private val Regex = """([A-Z]{2})([A-Z0-9]{3})""".r 14 | 15 | private lazy val isoCountries = Locale.getISOCountries 16 | 17 | private case class Impl( 18 | countryCode: String, 19 | partyId: String 20 | ) extends GlobalPartyId 21 | 22 | def apply(countryCode: String, partyId: String): GlobalPartyId = 23 | apply(countryCode + partyId) 24 | 25 | def apply(globalPartyId: String): GlobalPartyId = 26 | globalPartyId.toUpperCase match { 27 | case Regex(countryCode, partyId) if isoCountries.contains(countryCode) => 28 | Impl(countryCode, partyId) 29 | 30 | case _ => throw new IllegalArgumentException(s"$globalPartyId is not a valid global party id; " + 31 | s"must be an ISO 3166-1 alpha-2 country code, followed by a party id of 3 ASCII letters or digits") 32 | } 33 | 34 | def unapply(globalPartyId: GlobalPartyId): Option[(String, String)] = 35 | Some((globalPartyId.countryCode, globalPartyId.partyId)) 36 | 37 | } 38 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/OcpiStatusCode.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | sealed trait OcpiStatusCode { 4 | def code: Int 5 | } 6 | 7 | object OcpiStatusCode { 8 | def apply(code: Int): OcpiStatusCode = code match { 9 | case x if x >= 1000 && x <= 1999 => SuccessCode(code) 10 | case x if x >= 2000 && x <= 2999 => ClientErrorCode(code) 11 | case x if x >= 3000 && x <= 3999 => ServerErrorCode(code) 12 | case x => throw new RuntimeException(s"$x is not a valid OCPI Status Code") 13 | } 14 | 15 | sealed abstract case class SuccessCode private[OcpiStatusCode](code: Int) extends OcpiStatusCode 16 | object SuccessCode { 17 | private[OcpiStatusCode] def apply(code: Int) = new SuccessCode(code) {} 18 | } 19 | 20 | sealed trait ErrorCode extends OcpiStatusCode 21 | sealed abstract case class ClientErrorCode private[OcpiStatusCode](code: Int) extends ErrorCode 22 | object ClientErrorCode { 23 | private[OcpiStatusCode] def apply(code: Int) = new ClientErrorCode(code) {} 24 | } 25 | 26 | sealed abstract case class ServerErrorCode private[OcpiStatusCode](code: Int) extends ErrorCode 27 | object ServerErrorCode { 28 | private[OcpiStatusCode] def apply(code: Int) = new ServerErrorCode(code) {} 29 | } 30 | 31 | val GenericSuccess = SuccessCode(1000) 32 | val GenericClientFailure = ClientErrorCode(2000) 33 | val InvalidOrMissingParameters = ClientErrorCode(2001) 34 | val NotEnoughInformation = ClientErrorCode(2002) 35 | val UnknownLocation = ClientErrorCode(2003) 36 | val AuthenticationFailed = ClientErrorCode(2010) 37 | val MissingHeader = ClientErrorCode(2011) 38 | val PartyAlreadyRegistered = ClientErrorCode(2012) // When POSTing 39 | val RegistrationNotCompletedYetByParty = ClientErrorCode(2013) // When PUTing or GETing even 40 | val AuthorizationFailed = ClientErrorCode(2014) 41 | val ClientWasNotRegistered = ClientErrorCode(2015) 42 | 43 | val GenericServerFailure = ServerErrorCode(3000) 44 | val UnableToUseApi = ServerErrorCode(3001) 45 | val UnsupportedVersion = ServerErrorCode(3002) 46 | val MissingExpectedEndpoints = ServerErrorCode(3003) //TODO: TNM-2013 47 | val UnknownEndpointType = ServerErrorCode(3010) 48 | } 49 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/Versions.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package msgs 3 | 4 | import scala.util.{Success, Try} 5 | 6 | object Versions { 7 | 8 | case class Version(version: VersionNumber, url: Url) 9 | 10 | case class Endpoint(identifier: EndpointIdentifier, url: Url) 11 | 12 | case class VersionDetails(version: VersionNumber, endpoints: Iterable[Endpoint]) 13 | 14 | case class VersionNumber( 15 | major: Int, 16 | minor: Int, 17 | patch: Option[Int] = None 18 | ) extends Ordered[VersionNumber] { 19 | 20 | def compare(that: VersionNumber): Int = { 21 | def toNumber(v: VersionNumber) = v.major * 100 + v.minor * 10 + v.patch.getOrElse(0) 22 | toNumber(this) - toNumber(that) 23 | } 24 | 25 | override def toString = patch.foldLeft(s"$major.$minor")(_ + "." + _) 26 | } 27 | 28 | object VersionNumber { 29 | def apply(major: Int, minor: Int, patch: Int): VersionNumber = 30 | VersionNumber(major, minor, Some(patch)) 31 | 32 | def apply(value: String): VersionNumber = 33 | Try(value.split('.').map(_.toInt).toList) match { 34 | case Success(major :: minor :: Nil) => VersionNumber(major, minor) 35 | case Success(major :: minor :: patch :: Nil) => VersionNumber(major, minor, patch) 36 | case _ => throw new IllegalArgumentException(s"$value is not a valid version") 37 | } 38 | 39 | def opt(value: String): Option[VersionNumber] = Try(apply(value)).toOption 40 | 41 | val `2.0` = VersionNumber(2, 0) 42 | val `2.1` = VersionNumber(2, 1) 43 | val `2.1.1` = VersionNumber(2, 1, 1) 44 | } 45 | 46 | case class EndpointIdentifier(value: String) { 47 | override def toString = value 48 | } 49 | 50 | object EndpointIdentifier { 51 | val Locations = EndpointIdentifier("locations") 52 | val Credentials = EndpointIdentifier("credentials") 53 | val Versions = EndpointIdentifier("versions") 54 | val VersionDetails = EndpointIdentifier("version-details") 55 | val Tariffs = EndpointIdentifier("tariffs") 56 | val Tokens = EndpointIdentifier("tokens") 57 | val Cdrs = EndpointIdentifier("cdrs") 58 | val Sessions = EndpointIdentifier("sessions") 59 | val Commands = EndpointIdentifier("commands") 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/common.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import java.security.SecureRandom 4 | import Ownership.Theirs 5 | 6 | sealed trait Ownership { 7 | type Opposite <: Ownership 8 | } 9 | 10 | object Ownership { 11 | trait Theirs extends Ownership { 12 | type Opposite = Ours 13 | } 14 | trait Ours extends Ownership { 15 | type Opposite = Theirs 16 | } 17 | } 18 | 19 | case class AuthToken[O <: Ownership](value: String) { 20 | override def toString = value.substring(0, 3) + "..." 21 | require(value.length <= 64) 22 | } 23 | 24 | object AuthToken { 25 | private val TOKEN_LENGTH = 32 26 | private val TOKEN_CHARS = 27 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._" 28 | private val secureRandom = new SecureRandom() 29 | 30 | private def generateToken(length: Int): AuthToken[Theirs] = 31 | AuthToken[Theirs]((1 to length).map(_ => TOKEN_CHARS(secureRandom.nextInt(TOKEN_CHARS.length))).mkString) 32 | 33 | def generateTheirs: AuthToken[Theirs] = generateToken(TOKEN_LENGTH) 34 | } 35 | 36 | trait CountryCode extends Any { def value: String } 37 | object CountryCode { 38 | private case class Impl(value: String) extends AnyVal with CountryCode 39 | 40 | def apply(value: String): CountryCode = { 41 | require(value.length == 3, s"Must be a 3-letter, ISO 3166-1 country code; got $value") 42 | Impl(value.toUpperCase) 43 | } 44 | 45 | def unapply(cc: CountryCode): Option[String] = Some(cc.value) 46 | } 47 | 48 | trait Language extends Any { def value: String } 49 | object Language { 50 | private case class Impl(value: String) extends AnyVal with Language 51 | 52 | def apply(value: String): Language = { 53 | require(value.length == 2, s"Must be a 2-letter, ISO 639-1 language code; got $value") 54 | Impl(value.toUpperCase) 55 | } 56 | 57 | def unapply(l: Language): Option[String] = Some(l.value) 58 | } 59 | 60 | case class Url(value: String) extends AnyVal { 61 | def /(segment: String) = Url(value + (if (value.endsWith("/")) "" else "/") + segment) 62 | 63 | override def toString: String = value 64 | } 65 | 66 | trait CurrencyCode extends Any { def value: String } 67 | object CurrencyCode { 68 | private case class Impl(value: String) extends AnyVal with CurrencyCode 69 | 70 | def apply(value: String): CurrencyCode = { 71 | require(value.length == 3, s"Must be a 3-letter, ISO 4217 Code; got $value") 72 | Impl(value.toUpperCase) 73 | } 74 | 75 | def unapply(cc: CurrencyCode): Option[String] = Some(cc.value) 76 | } 77 | 78 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/resource.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Id 4 | 5 | 6 | sealed trait ResourceType { 7 | type F[_] 8 | } 9 | 10 | object ResourceType { 11 | trait Full extends ResourceType { type F[_] = Id[_] } 12 | trait Patch extends ResourceType { type F[_] = Option[_] } 13 | } 14 | 15 | trait Resource[RT <: ResourceType] 16 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/response.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import OcpiStatusCode.{ErrorCode, SuccessCode} 6 | 7 | trait OcpiResponse[Code <: OcpiStatusCode] { 8 | def statusCode: Code 9 | def statusMessage: Option[String] 10 | def timestamp: ZonedDateTime 11 | } 12 | 13 | case class ErrorResp( 14 | statusCode: ErrorCode, 15 | statusMessage: Option[String] = None, 16 | timestamp: ZonedDateTime = ZonedDateTime.now 17 | ) extends OcpiResponse[ErrorCode] 18 | 19 | case class SuccessResp[D]( 20 | statusCode: SuccessCode, 21 | statusMessage: Option[String] = None, 22 | timestamp: ZonedDateTime = ZonedDateTime.now, 23 | data: D 24 | ) extends OcpiResponse[SuccessCode] 25 | 26 | object SuccessResp { 27 | def apply( 28 | statusCode: SuccessCode, 29 | statusMessage: Option[String], 30 | timestamp: ZonedDateTime 31 | ): SuccessResp[Unit] = 32 | SuccessResp[Unit](statusCode, statusMessage, timestamp, ()) 33 | 34 | def apply( 35 | statusCode: SuccessCode 36 | ): SuccessResp[Unit] = 37 | SuccessResp(statusCode, None, ZonedDateTime.now) 38 | 39 | def apply( 40 | statusCode: SuccessCode, 41 | statusMessage: String 42 | ): SuccessResp[Unit] = 43 | SuccessResp(statusCode, Some(statusMessage), ZonedDateTime.now) 44 | } -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Cdrs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import com.thenewmotion.ocpi.msgs.ResourceType.Full 6 | import com.thenewmotion.ocpi.msgs.{CurrencyCode, Resource} 7 | import com.thenewmotion.ocpi.{Enumerable, Nameable} 8 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.Location 9 | import com.thenewmotion.ocpi.msgs.v2_1.Tariffs.Tariff 10 | 11 | object Cdrs { 12 | sealed abstract class AuthMethod(val name: String) extends Nameable 13 | implicit object AuthMethod extends Enumerable[AuthMethod] { 14 | case object AuthRequest extends AuthMethod("AUTH_REQUEST") 15 | case object Whitelist extends AuthMethod("WHITELIST") 16 | val values = Set(AuthRequest, Whitelist) 17 | } 18 | 19 | sealed abstract class CdrDimensionType(val name: String) extends Nameable 20 | implicit object CdrDimensionType extends Enumerable[CdrDimensionType] { 21 | case object Energy extends CdrDimensionType("ENERGY") 22 | case object Flat extends CdrDimensionType("FLAT") 23 | case object MaxCurrent extends CdrDimensionType("MAX_CURRENT") 24 | case object MinCurrent extends CdrDimensionType("MIN_CURRENT") 25 | case object ParkingTime extends CdrDimensionType("PARKING_TIME") 26 | case object Time extends CdrDimensionType("TIME") 27 | val values = Set(Energy, Flat, MaxCurrent, MinCurrent, ParkingTime, Time) 28 | } 29 | 30 | final case class CdrDimension( 31 | `type`: CdrDimensionType, 32 | volume: BigDecimal 33 | ) 34 | 35 | final case class ChargingPeriod( 36 | startDateTime: ZonedDateTime, 37 | dimensions: Iterable[CdrDimension] 38 | ) 39 | 40 | trait CdrId extends Any { def value: String } 41 | object CdrId { 42 | private class CdrIdImpl(val value: String) extends CdrId { 43 | override def toString: String = value 44 | 45 | override def equals(obj: scala.Any): Boolean = obj match { 46 | case CdrId(x) if x.toUpperCase.equals(value.toUpperCase) => true 47 | case _ => false 48 | } 49 | 50 | override def hashCode(): Int = value.hashCode 51 | } 52 | 53 | def apply(value: String): CdrId = { 54 | require(value.length <= 36, "Cdr Id must be 36 characters or less") 55 | require(value.nonEmpty, "Cdr Id cannot be an empty string") 56 | new CdrIdImpl(value) 57 | } 58 | 59 | def unapply(conId: CdrId): Option[String] = 60 | Some(conId.value) 61 | } 62 | 63 | final case class Cdr( 64 | id: CdrId, 65 | startDateTime: ZonedDateTime, 66 | stopDateTime: ZonedDateTime, 67 | authId: String, 68 | authMethod: AuthMethod, 69 | location: Location, 70 | meterId: Option[String] = None, 71 | currency: CurrencyCode, 72 | tariffs: Option[Iterable[Tariff]] = None, 73 | chargingPeriods: Iterable[ChargingPeriod], 74 | totalCost: BigDecimal, 75 | totalEnergy: BigDecimal, 76 | totalTime: BigDecimal, 77 | totalParkingTime: Option[BigDecimal] = None, 78 | remark: Option[String] = None, 79 | lastUpdated: ZonedDateTime 80 | ) extends Resource[Full] 81 | } 82 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Commands.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package v2_1 3 | 4 | import java.time.ZonedDateTime 5 | import java.util.UUID 6 | 7 | import Locations.{ConnectorId, EvseUid, LocationId} 8 | import Sessions.SessionId 9 | import Tokens.Token 10 | import com.thenewmotion.ocpi.{Enumerable, Nameable} 11 | 12 | object Commands { 13 | 14 | sealed abstract class CommandName(val name: String) extends Nameable 15 | object CommandName extends Enumerable[CommandName] { 16 | case object ReserveNow extends CommandName("RESERVE_NOW") 17 | case object StartSession extends CommandName("START_SESSION") 18 | case object StopSession extends CommandName("STOP_SESSION") 19 | case object UnlockConnector extends CommandName("UNLOCK_CONNECTOR") 20 | val values = Iterable(ReserveNow, StartSession, StopSession, UnlockConnector) 21 | } 22 | 23 | abstract class Command(val name: CommandName) { 24 | def responseUrl: Url 25 | } 26 | 27 | private[ocpi] def callbackUrl( 28 | baseUrl: Url, 29 | cmdId: UUID 30 | ): Url = baseUrl / cmdId.toString 31 | 32 | object Command { 33 | case class ReserveNow( 34 | responseUrl: Url, 35 | token: Token, 36 | expiryDate: ZonedDateTime, 37 | reservationId: Int, 38 | locationId: LocationId, 39 | evseUid: Option[EvseUid] 40 | ) extends Command(CommandName.ReserveNow) 41 | 42 | object ReserveNow { 43 | def apply( 44 | baseUrl: Url, 45 | commandId: UUID, 46 | token: Token, 47 | expiryDate: ZonedDateTime, 48 | reservationId: Int, 49 | locationId: LocationId, 50 | evseUid: Option[EvseUid] 51 | ): ReserveNow = 52 | ReserveNow( 53 | callbackUrl(baseUrl, commandId), 54 | token, 55 | expiryDate, 56 | reservationId, 57 | locationId, 58 | evseUid 59 | ) 60 | } 61 | 62 | case class StartSession( 63 | responseUrl: Url, 64 | token: Token, 65 | locationId: LocationId, 66 | evseUid: Option[EvseUid] 67 | ) extends Command(CommandName.StartSession) 68 | 69 | object StartSession { 70 | def apply( 71 | baseUrl: Url, 72 | commandId: UUID, 73 | token: Token, 74 | locationId: LocationId, 75 | evseUid: Option[EvseUid] 76 | ): StartSession = 77 | StartSession( 78 | callbackUrl(baseUrl, commandId), 79 | token, 80 | locationId, 81 | evseUid 82 | ) 83 | } 84 | 85 | case class StopSession( 86 | responseUrl: Url, 87 | sessionId: SessionId 88 | ) extends Command(CommandName.StopSession) 89 | 90 | object StopSession { 91 | def apply( 92 | baseUrl: Url, 93 | commandId: UUID, 94 | sessionId: SessionId 95 | ): StopSession = 96 | StopSession( 97 | callbackUrl(baseUrl, commandId), 98 | sessionId 99 | ) 100 | } 101 | 102 | case class UnlockConnector( 103 | responseUrl: Url, 104 | locationId: LocationId, 105 | evseUid: EvseUid, 106 | connectorId: ConnectorId 107 | ) extends Command(CommandName.UnlockConnector) 108 | 109 | object UnlockConnector { 110 | def apply( 111 | baseUrl: Url, 112 | commandId: UUID, 113 | locationId: LocationId, 114 | evseUid: EvseUid, 115 | connectorId: ConnectorId 116 | ): UnlockConnector = 117 | UnlockConnector( 118 | callbackUrl(baseUrl, commandId), 119 | locationId, evseUid, connectorId 120 | ) 121 | } 122 | } 123 | 124 | sealed abstract class CommandResponseType(val name: String) extends Nameable 125 | implicit object CommandResponseType extends Enumerable[CommandResponseType] { 126 | case object NotSupported extends CommandResponseType("NOT_SUPPORTED") 127 | case object Rejected extends CommandResponseType("REJECTED") 128 | case object Accepted extends CommandResponseType("ACCEPTED") 129 | case object Timeout extends CommandResponseType("TIMEOUT") 130 | case object UnknownSession extends CommandResponseType("UNKNOWN_SESSION") 131 | val values = Iterable(NotSupported, Rejected, Accepted, Timeout, UnknownSession) 132 | } 133 | 134 | case class CommandResponse(result: CommandResponseType) 135 | } 136 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/CommonTypes.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package msgs 3 | package v2_1 4 | 5 | object CommonTypes { 6 | 7 | case class DisplayText( 8 | language: String, 9 | text: String 10 | ) 11 | 12 | sealed trait ImageCategory extends Nameable 13 | implicit object ImageCategory extends Enumerable[ImageCategory] { 14 | case object Charger extends ImageCategory {val name = "CHARGER"} 15 | case object Entrance extends ImageCategory {val name = "ENTRANCE"} 16 | case object Location extends ImageCategory {val name = "LOCATION"} 17 | case object Network extends ImageCategory {val name = "NETWORK"} 18 | case object Operator extends ImageCategory {val name = "OPERATOR"} 19 | case object Other extends ImageCategory {val name = "OTHER"} 20 | case object Owner extends ImageCategory {val name = "OWNER"} 21 | val values = Iterable(Charger, Entrance, Location, Network, Operator, Other, Owner) 22 | } 23 | 24 | case class Image( 25 | url: Url, 26 | category: ImageCategory, 27 | `type`: String, 28 | width: Option[Int] = None, 29 | height: Option[Int] = None, 30 | thumbnail: Option[Url] = None 31 | ) 32 | 33 | case class BusinessDetails( 34 | name: String, 35 | logo: Option[Image], 36 | website: Option[Url] 37 | ) 38 | 39 | } 40 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Credentials.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | package v2_1 3 | 4 | import CommonTypes.BusinessDetails 5 | 6 | object Credentials { 7 | 8 | case class Creds[O <: Ownership]( 9 | token: AuthToken[O#Opposite], 10 | url: Url, 11 | businessDetails: BusinessDetails, 12 | globalPartyId: GlobalPartyId 13 | ) 14 | } 15 | 16 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Sessions.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import com.thenewmotion.ocpi.msgs.ResourceType.{Full, Patch} 6 | import com.thenewmotion.ocpi.{Enumerable, Nameable} 7 | import com.thenewmotion.ocpi.msgs.{CurrencyCode, Resource, ResourceType} 8 | import Cdrs.{AuthMethod, ChargingPeriod} 9 | import Locations.Location 10 | import Tokens.AuthId 11 | 12 | object Sessions { 13 | 14 | trait SessionId extends Any { def value: String } 15 | object SessionId { 16 | private case class SessionIdImpl(value: String) extends AnyVal with SessionId { 17 | override def toString: String = value 18 | } 19 | 20 | def apply(value: String): SessionId = { 21 | require(value.length <= 36, "Session Id must be 36 characters or less") 22 | require(value.nonEmpty, "Session Id cannot be an empty string") 23 | SessionIdImpl(value) 24 | } 25 | 26 | def unapply(id: SessionId): Option[String] = Some(id.value) 27 | } 28 | 29 | sealed abstract class SessionStatus(val name: String) extends Nameable 30 | implicit object SessionStatus extends Enumerable[SessionStatus] { 31 | case object Active extends SessionStatus("ACTIVE") 32 | case object Completed extends SessionStatus("COMPLETED") 33 | case object Invalid extends SessionStatus("INVALID") 34 | case object Pending extends SessionStatus("PENDING") 35 | val values = Iterable(Active, Completed, Invalid, Pending) 36 | } 37 | 38 | trait BaseSession[RT <: ResourceType] extends Resource[RT] { 39 | def startDatetime: RT#F[ZonedDateTime] 40 | def endDatetime: Option[ZonedDateTime] 41 | def kwh: RT#F[BigDecimal] 42 | def authId: RT#F[AuthId] 43 | def authMethod: RT#F[AuthMethod] 44 | def location: RT#F[Location] 45 | def meterId: Option[String] 46 | def currency: RT#F[CurrencyCode] 47 | def chargingPeriods: RT#F[Seq[ChargingPeriod]] 48 | def totalCost: Option[BigDecimal] 49 | def status: RT#F[SessionStatus] 50 | def lastUpdated: RT#F[ZonedDateTime] 51 | } 52 | 53 | case class Session( 54 | id: SessionId, 55 | startDatetime: ZonedDateTime, 56 | endDatetime: Option[ZonedDateTime], 57 | kwh: BigDecimal, 58 | authId: AuthId, 59 | authMethod: AuthMethod, 60 | location: Location, 61 | meterId: Option[String], 62 | currency: CurrencyCode, 63 | chargingPeriods: Seq[ChargingPeriod] = Nil, 64 | totalCost: Option[BigDecimal], 65 | status: SessionStatus, 66 | lastUpdated: ZonedDateTime 67 | ) extends BaseSession[Full] { 68 | require(location.evses.toSeq.length == 1, "Session Location must have one Evse") 69 | require(location.evses.flatMap(_.connectors).toSeq.length == 1, "Session Location must have one Connector") 70 | } 71 | 72 | case class SessionPatch( 73 | startDatetime: Option[ZonedDateTime] = None, 74 | endDatetime: Option[ZonedDateTime] = None, 75 | kwh: Option[BigDecimal] = None, 76 | authId: Option[AuthId] = None, 77 | authMethod: Option[AuthMethod] = None, 78 | location: Option[Location] = None, 79 | meterId: Option[String] = None, 80 | currency: Option[CurrencyCode] = None, 81 | chargingPeriods: Option[Seq[ChargingPeriod]] = None, 82 | totalCost: Option[BigDecimal] = None, 83 | status: Option[SessionStatus] = None, 84 | lastUpdated: Option[ZonedDateTime] = None 85 | ) extends BaseSession[Patch] { 86 | require(location.fold(1)(_.evses.toSeq.length) == 1, "Session Location must have one Evse") 87 | require( 88 | location.fold(1)(_.evses.flatMap(_.connectors).toSeq.length) == 1, 89 | "Session Location must have one Connector" 90 | ) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Tariffs.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import java.time.{Duration, LocalDate, LocalTime, ZonedDateTime} 4 | 5 | import CommonTypes.DisplayText 6 | import Locations.EnergyMix 7 | import com.thenewmotion.ocpi.msgs.ResourceType.Full 8 | import com.thenewmotion.ocpi.msgs.{CurrencyCode, Resource, Url} 9 | import com.thenewmotion.ocpi.{Enumerable, Nameable} 10 | 11 | object Tariffs { 12 | sealed abstract class DayOfWeek(val name: String) extends Nameable 13 | implicit object DayOfWeek extends Enumerable[DayOfWeek] { 14 | case object Monday extends DayOfWeek("MONDAY") 15 | case object Tuesday extends DayOfWeek("TUESDAY") 16 | case object Wednesday extends DayOfWeek("WEDNESDAY") 17 | case object Thursday extends DayOfWeek("THURSDAY") 18 | case object Friday extends DayOfWeek("FRIDAY") 19 | case object Saturday extends DayOfWeek("SATURDAY") 20 | case object Sunday extends DayOfWeek("SUNDAY") 21 | val values = Set(Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday) 22 | } 23 | 24 | sealed abstract class TariffDimensionType(val name: String) extends Nameable 25 | implicit object TariffDimensionType extends Enumerable[TariffDimensionType] { 26 | case object Energy extends TariffDimensionType("ENERGY") 27 | case object Flat extends TariffDimensionType("FLAT") 28 | case object ParkingTime extends TariffDimensionType("PARKING_TIME") 29 | case object Time extends TariffDimensionType("TIME") 30 | val values = Set(Energy, Flat, ParkingTime, Time) 31 | } 32 | 33 | final case class PriceComponent( 34 | `type`: TariffDimensionType, 35 | price: BigDecimal, 36 | stepSize: Int 37 | ) 38 | 39 | final case class TariffRestrictions( 40 | startTime: Option[LocalTime] = None, 41 | endTime: Option[LocalTime] = None, 42 | startDate: Option[LocalDate] = None, 43 | endDate: Option[LocalDate] = None, 44 | minKwh: Option[BigDecimal] = None, 45 | maxKwh: Option[BigDecimal] = None, 46 | minPower: Option[BigDecimal] = None, 47 | maxPower: Option[BigDecimal] = None, 48 | minDuration: Option[Duration] = None, 49 | maxDuration: Option[Duration] = None, 50 | dayOfWeek: Option[Iterable[DayOfWeek]] = None 51 | ) 52 | 53 | final case class TariffElement( 54 | priceComponents: Iterable[PriceComponent], 55 | restrictions: Option[TariffRestrictions] = None 56 | ) 57 | 58 | trait TariffId extends Any { def value: String } 59 | object TariffId { 60 | private case class TariffIdImpl(value: String) extends AnyVal with TariffId { 61 | override def toString: String = value 62 | } 63 | 64 | def apply(value: String): TariffId = { 65 | require(value.length <= 36, "Tariff Id must be 36 characters or less") 66 | require(value.nonEmpty, "Tariff Id cannot be an empty string") 67 | TariffIdImpl(value) 68 | } 69 | 70 | def unapply(conId: TariffId): Option[String] = 71 | Some(conId.value) 72 | } 73 | 74 | final case class Tariff( 75 | id: TariffId, 76 | currency: CurrencyCode, 77 | tariffAltText: Option[Iterable[DisplayText]] = None, 78 | tariffAltUrl: Option[Url] = None, 79 | elements: Iterable[TariffElement], 80 | energyMix: Option[EnergyMix] = None, 81 | lastUpdated: ZonedDateTime 82 | ) extends Resource[Full] 83 | } 84 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/Tokens.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | package msgs.v2_1 3 | 4 | import java.time.ZonedDateTime 5 | 6 | import com.thenewmotion.ocpi.msgs.ResourceType.{Full, Patch} 7 | import com.thenewmotion.ocpi.msgs.{Language, Resource, ResourceType} 8 | import com.thenewmotion.ocpi.msgs.v2_1.CommonTypes.DisplayText 9 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.{ConnectorId, EvseUid, LocationId} 10 | 11 | object Tokens { 12 | sealed abstract class TokenType(val name: String) extends Nameable 13 | implicit object TokenType extends Enumerable[TokenType] { 14 | case object Other extends TokenType("OTHER") 15 | case object Rfid extends TokenType("RFID") 16 | val values = Set(Other, Rfid) 17 | } 18 | 19 | sealed abstract class WhitelistType(val name: String) extends Nameable 20 | implicit object WhitelistType extends Enumerable[WhitelistType] { 21 | case object Always extends WhitelistType("ALWAYS") 22 | case object Allowed extends WhitelistType("ALLOWED") 23 | case object AllowedOffline extends WhitelistType("ALLOWED_OFFLINE") 24 | case object Never extends WhitelistType("NEVER") 25 | val values = Set(Always, Allowed, AllowedOffline, Never) 26 | } 27 | 28 | trait TokenUid extends Any { def value: String } 29 | object TokenUid { 30 | private case class TokenUidImpl(value: String) extends AnyVal with TokenUid { 31 | override def toString: String = value 32 | } 33 | 34 | def apply(value: String): TokenUid = { 35 | require(value.length <= 36, "Token Uid must be 36 characters or less") 36 | require(value.nonEmpty, "Token Id cannot be an empty string") 37 | TokenUidImpl(value) 38 | } 39 | 40 | def unapply(tokId: TokenUid): Option[String] = 41 | Some(tokId.value) 42 | } 43 | 44 | trait AuthId extends Any { def value: String } 45 | object AuthId { 46 | private case class AuthIdImpl(value: String) extends AnyVal with AuthId { 47 | override def toString: String = value 48 | } 49 | 50 | def apply(value: String): AuthId = { 51 | require(value.length <= 36, "Auth Id must be 36 characters or less") 52 | require(value.nonEmpty, "Auth Id cannot be an empty string") 53 | AuthIdImpl(value) 54 | } 55 | 56 | def unapply(id: AuthId): Option[String] = 57 | Some(id.value) 58 | } 59 | 60 | trait BaseToken[RT <: ResourceType] extends Resource[RT] { 61 | def `type`: RT#F[TokenType] 62 | def authId: RT#F[AuthId] 63 | def visualNumber: Option[String] 64 | def issuer: RT#F[String] 65 | def valid: RT#F[Boolean] 66 | def whitelist: RT#F[WhitelistType] 67 | def language: Option[Language] 68 | def lastUpdated: RT#F[ZonedDateTime] 69 | } 70 | 71 | case class Token( 72 | uid: TokenUid, 73 | `type`: TokenType, 74 | authId: AuthId, 75 | visualNumber: Option[String] = None, 76 | issuer: String, 77 | valid: Boolean, 78 | whitelist: WhitelistType, 79 | language: Option[Language] = None, 80 | lastUpdated: ZonedDateTime 81 | ) extends BaseToken[Full] 82 | 83 | case class TokenPatch( 84 | `type`: Option[TokenType] = None, 85 | authId: Option[AuthId] = None, 86 | visualNumber: Option[String] = None, 87 | issuer: Option[String] = None, 88 | valid: Option[Boolean] = None, 89 | whitelist: Option[WhitelistType] = None, 90 | language: Option[Language] = None, 91 | lastUpdated: Option[ZonedDateTime] = None 92 | ) extends BaseToken[Patch] 93 | 94 | case class LocationReferences( 95 | locationId: LocationId, 96 | evseUids: Iterable[EvseUid] = Nil, 97 | connectorIds: Iterable[ConnectorId] = Nil 98 | ) 99 | 100 | sealed abstract class Allowed(val name: String) extends Nameable 101 | implicit object Allowed extends Enumerable[Allowed] { 102 | case object Allowed extends Allowed("ALLOWED") 103 | case object Blocked extends Allowed("BLOCKED") 104 | case object Expired extends Allowed("EXPIRED") 105 | case object NoCredit extends Allowed("NO_CREDIT") 106 | case object NotAllowed extends Allowed("NOT_ALLOWED") 107 | 108 | val values = Set(Allowed, Blocked, Expired, NoCredit, NotAllowed) 109 | } 110 | 111 | case class AuthorizationInfo( 112 | allowed: Allowed, 113 | location: Option[LocationReferences] = None, 114 | info: Option[DisplayText] = None 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /msgs/src/main/scala/com/thenewmotion/ocpi/msgs/v2_1/package.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | package object v2_1 { 4 | type Id[T] = T 5 | } 6 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/OcpiDateTimeParserSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | import java.time.{ZoneOffset, ZonedDateTime} 4 | 5 | import org.specs2.mutable.Specification 6 | import org.specs2.specification.Scope 7 | 8 | class OcpiDateTimeParserSpec extends Specification{ 9 | 10 | sequential 11 | 12 | "OcpiDateTimeParser" should { 13 | 14 | "format a given datetime correctly" in new Scope { 15 | ZonedDateTimeParser.format( 16 | ZonedDateTime.of(2016, 11, 7, 11, 31, 43, 0, ZoneOffset.UTC)) mustEqual "2016-11-07T11:31:43Z" 17 | } 18 | 19 | val withZliteral = "2016-11-07T11:31:43Z" 20 | s"parse pattern with 'Z' literal, e.g.: $withZliteral" in new Scope { 21 | ZonedDateTimeParser.parseOpt(withZliteral) must not beEmpty 22 | } 23 | 24 | val withoutOffsetOrZliteral = "2016-11-07T11:31:43" 25 | s"parse pattern without offset or 'Z' literal, e.g.: $withoutOffsetOrZliteral" in new Scope { 26 | val noTz = ZonedDateTimeParser.parseOpt(withoutOffsetOrZliteral) 27 | noTz.isDefined mustEqual true 28 | noTz.get must_== ZonedDateTime.of(2016, 11, 7, 11, 31, 43, 0, ZoneOffset.UTC) 29 | } 30 | 31 | val withMillisAndOffset = "2016-11-07T12:31:43+01:00" 32 | s"fail to parse pattern with offset other than UTC, e.g.: $withMillisAndOffset " in new Scope { 33 | ZonedDateTimeParser.parse(withMillisAndOffset) must throwA[IllegalArgumentException] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/CdrIdSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.CdrId 4 | import org.specs2.mutable.Specification 5 | 6 | class CdrIdSpec extends Specification { 7 | "CdrId" should { 8 | "be case insensitive" >> { 9 | CdrId("ABC") mustEqual CdrId("abC") 10 | } 11 | 12 | "be 36 chars or less" >> { 13 | CdrId("1234567890123456789012345678901234567890") must throwA[IllegalArgumentException] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/GlobalPartyIdSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class GlobalPartyIdSpec extends Specification { 6 | 7 | "GlobalPartyId" should { 8 | "Accept a valid country code and party id combo, uppercasing if nescessary" >> { 9 | val res = GlobalPartyId("nl", "tnm") 10 | res.countryCode mustEqual "NL" 11 | res.partyId mustEqual "TNM" 12 | } 13 | 14 | "Accept a single string containing country code and party id combo, uppercasing if nescessary" >> { 15 | val res = GlobalPartyId("nltnm") 16 | res.countryCode mustEqual "NL" 17 | res.partyId mustEqual "TNM" 18 | } 19 | 20 | "throw a wobbly when given" >> { 21 | "an invalid country code" >> { 22 | GlobalPartyId("zz", "tnm") must throwA[IllegalArgumentException] 23 | } 24 | 25 | "a too long country code" >> { 26 | GlobalPartyId("NLD", "tnm") must throwA[IllegalArgumentException] 27 | } 28 | 29 | "a too short country code" >> { 30 | GlobalPartyId("N", "tnm") must throwA[IllegalArgumentException] 31 | } 32 | 33 | "a country code with invalid chars" >> { 34 | GlobalPartyId("++", "tnm") must throwA[IllegalArgumentException] 35 | } 36 | 37 | "a too short party id" >> { 38 | GlobalPartyId("nl", "tn") must throwA[IllegalArgumentException] 39 | } 40 | 41 | "a too long party id" >> { 42 | GlobalPartyId("nl", "tnnm") must throwA[IllegalArgumentException] 43 | } 44 | 45 | "a party id with invalid chars" >> { 46 | GlobalPartyId("nl", "+-$") must throwA[IllegalArgumentException] 47 | } 48 | 49 | "a single string of invalid length" >> { 50 | GlobalPartyId("a") must throwA[IllegalArgumentException] 51 | } 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/VersionNumberSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs 2 | 3 | import com.thenewmotion.ocpi.msgs.Versions.VersionNumber 4 | import org.specs2.mutable.Specification 5 | 6 | class VersionNumberSpec extends Specification { 7 | 8 | "VersionNumber" should { 9 | "decode regular version string" >> { 10 | VersionNumber("2.1") mustEqual VersionNumber(2, 1) 11 | } 12 | 13 | "decode patch version string" >> { 14 | VersionNumber("2.1.1") mustEqual VersionNumber(2, 1, Some(1)) 15 | } 16 | 17 | "toString without patch" >> { 18 | VersionNumber(2, 1).toString mustEqual "2.1" 19 | } 20 | 21 | "toString with patch" >> { 22 | VersionNumber(2, 1, 1).toString mustEqual "2.1.1" 23 | } 24 | 25 | "throw an error given an invalid version string" >> { 26 | VersionNumber("a.b") must throwA[IllegalArgumentException] 27 | } 28 | 29 | "be correctly comparable" >> { 30 | VersionNumber("2.1") > VersionNumber("2.0") 31 | VersionNumber("2.1") == VersionNumber("2.1") 32 | VersionNumber("2.1") == VersionNumber("2.1.0") 33 | VersionNumber("2.1.1") > VersionNumber("2.1") 34 | VersionNumber("2.1.2") > VersionNumber("2.1.1") 35 | VersionNumber("2.2.1") > VersionNumber("2.1.1") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/CommandsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import java.util.UUID 4 | 5 | import com.thenewmotion.ocpi.msgs.Url 6 | import org.specs2.mutable.Specification 7 | 8 | class CommandsSpec extends Specification { 9 | 10 | "Commands" should { 11 | "Construct a callback url" >> { 12 | val uuid = UUID.randomUUID() 13 | Commands.callbackUrl(Url("http://blah.com/commands"), uuid) mustEqual 14 | Url(s"http://blah.com/commands/$uuid") 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/LocationsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import com.thenewmotion.ocpi.msgs.v2_1.Locations.{Latitude, Longitude, RegularHours} 4 | import org.specs2.mutable.Specification 5 | 6 | class LocationsSpec extends Specification { 7 | 8 | "Latitude" should { 9 | "Validate range" >> { 10 | Latitude(34.546565) must not(throwA[Exception]) 11 | Latitude(-92) must throwA[IllegalArgumentException] 12 | Latitude(92) must throwA[IllegalArgumentException] 13 | } 14 | 15 | "Trim decimal places to 6, when given a double, non strict mode" >> { 16 | Latitude(34.45775663434).value mustEqual 34.457757 17 | Latitude(34.45775663434).toString mustEqual "34.457757" 18 | } 19 | 20 | "Error when given a double with too much precision, strict mode" >> { 21 | Latitude.strict(34.45775663434) must throwA[IllegalArgumentException] 22 | } 23 | 24 | "Accept a double with correct precision, strict mode" >> { 25 | Latitude.strict(34.457756) must not(throwA[IllegalArgumentException]) 26 | } 27 | 28 | "Parse a string" >> { 29 | Latitude("34.45775663434").value mustEqual 34.457757 30 | Latitude("abc") must throwA[IllegalArgumentException] 31 | } 32 | } 33 | 34 | "Longitude" should { 35 | "Validate range" >> { 36 | Longitude(34.546565) must not(throwA[Exception]) 37 | Longitude(92) must not(throwA[IllegalArgumentException]) 38 | Longitude(-181) must throwA[IllegalArgumentException] 39 | Longitude(181) must throwA[IllegalArgumentException] 40 | } 41 | 42 | "Trim decimal places to 6, when given a double, non strict mode" >> { 43 | Longitude(34.45775663434).value mustEqual 34.457757 44 | Longitude(34.45775663434).toString mustEqual "34.457757" 45 | } 46 | 47 | "Error when given a double with too much precision, strict mode" >> { 48 | Longitude.strict(34.45775663434) must throwA[IllegalArgumentException] 49 | } 50 | 51 | "Accept a double with correct precision, strict mode" >> { 52 | Longitude.strict(34.457756) must not(throwA[IllegalArgumentException]) 53 | } 54 | 55 | "Parse a string" >> { 56 | Longitude("34.45775663434").value mustEqual 34.457757 57 | Longitude("abc") must throwA[IllegalArgumentException] 58 | } 59 | } 60 | 61 | "RegularHours" should { 62 | "Accept 00:00 as an ending time" >> { 63 | RegularHours(1, "17:00", "00:00") must not(throwA[IllegalArgumentException]) 64 | } 65 | 66 | "Accept 24:00 as an ending time" >> { 67 | RegularHours(1, "17:00", "24:00") must not(throwA[IllegalArgumentException]) 68 | } 69 | 70 | "Not accept 00:01 as an ending time" >> { 71 | RegularHours(1, "17:00", "00:01") must throwA[IllegalArgumentException] 72 | } 73 | 74 | "Not accept 24:01 as an ending time" >> { 75 | RegularHours(1, "17:00", "24:01") must throwA[IllegalArgumentException] 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /msgs/src/test/scala/com/thenewmotion/ocpi/msgs/v2_1/SessionsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi.msgs.v2_1 2 | 3 | import java.time.{ZoneOffset, ZonedDateTime} 4 | import java.time.format.DateTimeFormatter 5 | 6 | import com.thenewmotion.ocpi.msgs.{CountryCode, CurrencyCode} 7 | import com.thenewmotion.ocpi.msgs.v2_1.Cdrs.AuthMethod 8 | import com.thenewmotion.ocpi.msgs.v2_1.Locations._ 9 | import com.thenewmotion.ocpi.msgs.v2_1.Sessions.{Session, SessionId, SessionStatus} 10 | import com.thenewmotion.ocpi.msgs.v2_1.Tokens.AuthId 11 | import org.specs2.mutable.Specification 12 | 13 | class SessionsSpec extends Specification { 14 | 15 | "Session" should { 16 | "Accept Locations with exactly one Evse and one Connector" >> { 17 | val loc = location(evse(connector)) 18 | session1(loc) must not(throwA[Exception]) 19 | } 20 | 21 | "Error for Locations with more than one evse" >> { 22 | val loc = location(evse(connector), evse(connector)) 23 | session1(loc) must throwA[IllegalArgumentException] 24 | } 25 | 26 | "Error for Locations with more than one connector" >> { 27 | val loc = location(evse(connector, connector)) 28 | session1(loc) must throwA[IllegalArgumentException] 29 | } 30 | } 31 | 32 | private def parseToUtc(s: String) = 33 | ZonedDateTime.parse(s, DateTimeFormatter.ISO_OFFSET_DATE_TIME).withZoneSameInstant(ZoneOffset.UTC) 34 | 35 | private val dateOfUpdate = parseToUtc("2016-12-31T23:59:59Z") 36 | 37 | val connector = Connector( 38 | ConnectorId("1"), 39 | lastUpdated = dateOfUpdate, 40 | ConnectorType.`IEC_62196_T2`, 41 | ConnectorFormat.Cable, 42 | PowerType.AC3Phase, 43 | 230, 44 | 16, 45 | tariffId = Some("kwrate") 46 | ) 47 | 48 | def evse(connectors: Connector*) = Evse( 49 | EvseUid("BE-BEC-E041503001"), 50 | lastUpdated = dateOfUpdate, 51 | ConnectorStatus.Available, 52 | capabilities = List(Capability.Reservable), 53 | connectors = connectors, 54 | floorLevel = Some("-1"), 55 | physicalReference = Some("1") 56 | ) 57 | 58 | def location(evses: Evse*) = Location( 59 | LocationId("LOC1"), 60 | lastUpdated = dateOfUpdate, 61 | `type` = LocationType.OnStreet, 62 | Some("Gent Zuid"), 63 | address = "F.Rooseveltlaan 3A", 64 | city = "Gent", 65 | postalCode = "9000", 66 | country = CountryCode("BEL"), 67 | coordinates = GeoLocation(Latitude("3.729945"), Longitude("51.047594")), 68 | evses = evses, 69 | directions = List.empty, 70 | operator = None, 71 | suboperator = None, 72 | openingTimes = None, 73 | relatedLocations = List.empty, 74 | chargingWhenClosed = Some(true), 75 | images = List.empty, 76 | energyMix = Some(EnergyMix( 77 | isGreenEnergy = true, 78 | energySources = Nil, 79 | environImpact = Nil, 80 | Some("Greenpeace Energy eG"), 81 | Some("eco-power") 82 | )) 83 | ) 84 | 85 | def session1(location: Location) = Session( 86 | id = SessionId("abc"), 87 | startDatetime = parseToUtc("2017-03-01T08:00:00Z"), 88 | endDatetime = Some(parseToUtc("2017-03-01T10:00:00Z")), 89 | kwh = 1000, 90 | authId = AuthId("ABC1234"), 91 | authMethod = AuthMethod.AuthRequest, 92 | location = location, 93 | meterId = None, 94 | currency = CurrencyCode("EUR"), 95 | chargingPeriods = Nil, 96 | totalCost = Some(10.24), 97 | status = SessionStatus.Completed, 98 | lastUpdated = dateOfUpdate 99 | ) 100 | 101 | } 102 | -------------------------------------------------------------------------------- /prelude/src/main/scala/Enumerable.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | 4 | trait Enumerable[T <: Nameable] { 5 | def values: Iterable[T] 6 | def withName(name: String): Option[T] = values.find(_.name equals name) 7 | } 8 | -------------------------------------------------------------------------------- /prelude/src/main/scala/Nameable.scala: -------------------------------------------------------------------------------- 1 | package com.thenewmotion.ocpi 2 | 3 | 4 | trait Nameable { 5 | def name: String 6 | override def toString = name.toString 7 | } 8 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.5 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += "TNM public" at "https://nexus.thenewmotion.com/content/groups/public" 2 | 3 | addSbtPlugin("com.newmotion" % "sbt-build-seed" % "5.0.6" ) 4 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | version in ThisBuild := "2.0.1-M2-SNAPSHOT" 2 | --------------------------------------------------------------------------------