Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ocpi-2.2.1): Add Content-Type header on requests (if needed), on all responses & fixed headers case sensitivity #14

Merged
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,7 @@ ij_groovy_while_on_new_line = false
ij_groovy_wrap_long_lines = false

[{*.gradle.kts,*.kt,*.kts,*.main.kts}]
ktlint_disabled_rules = no-wildcard-imports, package-name, filename
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

Open Charge Point Interface (OCPI) java / kotlin library

## Setup

In your `build.gradle.kts`, add:
```kts
dependencies {
implementation("com.izivia:ocpi-2-2-1:0.0.13")
implementation("com.izivia:ocpi-transport:0.0.13")

// ... other dependencies
}
```

To see all available artifacts, go to: https://central.sonatype.com/search?namespace=com.izivia&q=ocpi

## Usage

### Server (CPO or eMSP)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@ import com.fasterxml.jackson.core.type.TypeReference
import com.izivia.ocpi.toolkit.modules.credentials.repositories.PlatformRepository
import com.izivia.ocpi.toolkit.modules.versions.domain.ModuleID
import com.izivia.ocpi.toolkit.transport.TransportClientBuilder
import com.izivia.ocpi.toolkit.transport.domain.HttpException
import com.izivia.ocpi.toolkit.transport.domain.HttpRequest
import com.izivia.ocpi.toolkit.transport.domain.HttpResponse
import com.izivia.ocpi.toolkit.transport.domain.HttpStatus
import com.izivia.ocpi.toolkit.transport.domain.*
import java.util.*

typealias AuthenticatedHttpRequest = HttpRequest

object Header {
const val AUTHORIZATION = "Authorization"
const val X_REQUEST_ID = "X-Request-ID"
const val X_CORRELATION_ID = "X-Correlation-ID"
const val X_TOTAL_COUNT = "X-Total-Count"
const val X_LIMIT = "X-Limit"
const val LINK = "Link"
const val CONTENT_TYPE = "Content-Type"
}

object ContentType {
const val APPLICATION_JSON = "application/json"
}

/**
* Parse body of a paginated request. The result will be stored in a SearchResult which contains all pagination
* information.
Expand All @@ -22,10 +33,10 @@ inline fun <reified T> HttpResponse.parsePaginatedBody(offset: Int): OcpiRespons
.let { parsedBody ->
OcpiResponseBody(
data = parsedBody.data?.toSearchResult(
totalCount = headers["X-Total-Count"]!!.toInt(),
limit = headers["X-Limit"]!!.toInt(),
totalCount = getHeader(Header.X_TOTAL_COUNT)!!.toInt(),
limit = getHeader(Header.X_LIMIT)!!.toInt(),
lilgallon marked this conversation as resolved.
Show resolved Hide resolved
offset = offset,
nextPageUrl = headers["Link"]?.split("<")?.get(1)?.split(">")?.first()
nextPageUrl = getHeader(Header.LINK)?.split("<")?.get(1)?.split(">")?.first()
),
status_code = parsedBody.status_code,
status_message = parsedBody.status_message,
Expand Down Expand Up @@ -56,7 +67,7 @@ fun String.decodeBase64(): String = Base64.getDecoder().decode(this).decodeToStr
/**
* Creates the authorization header from the given token
*/
fun authorizationHeader(token: String): Pair<String, String> = "Authorization" to "Token ${token.encodeBase64()}"
fun authorizationHeader(token: String): Pair<String, String> = Header.AUTHORIZATION to "Token ${token.encodeBase64()}"

/**
* Creates the authorization header by taking the right token in the platform repository
Expand Down Expand Up @@ -106,27 +117,45 @@ suspend fun HttpRequest.authenticate(
fun HttpRequest.authenticate(token: String): AuthenticatedHttpRequest =
withHeaders(headers = headers.plus(authorizationHeader(token = token)))

/**
* It adds Content-Type header as "application/json" if the body is not null.
*/
private fun HttpRequest.withContentTypeHeaderIfNeeded(): HttpRequest =
withHeaders(
headers = if (body != null) {
headers.plus(Header.CONTENT_TYPE to ContentType.APPLICATION_JSON)
} else {
headers
}
)

/**
* For debugging issues, OCPI implementations are required to include unique IDs via HTTP headers in every
* request/response
* request/response.
*
* - X-Request-ID: Every request SHALL contain a unique request ID, the response to this request SHALL contain the same
* ID.
* - X-Correlation-ID: Every request/response SHALL contain a unique correlation ID, every response to this request
* SHALL contain the same ID.
*
* Moreover, for requests, Content-Type SHALL be set to application/json for any request that contains a
* message body: POST, PUT and PATCH. When no body is present, probably in a GET or DELETE, then the Content-Type
* header MAY be omitted.
*
* This method should be called when doing the first request from a client.
*
* Dev note: When the server does a request (not a response), it must keep the same X-Correlation-ID but generate a new
* X-Request-ID. So don't call this method in that case.
*/
fun HttpRequest.withDebugHeaders(): HttpRequest =
fun HttpRequest.withRequiredHeaders(
requestId: String,
correlationId: String
): HttpRequest =
withHeaders(
headers = headers
.plus("X-Request-ID" to generateUUIDv4Token())
.plus("X-Correlation-ID" to generateUUIDv4Token())
)
.plus(Header.X_REQUEST_ID to requestId)
.plus(Header.X_CORRELATION_ID to correlationId)
).withContentTypeHeaderIfNeeded()

/**
* For debugging issues, OCPI implementations are required to include unique IDs via HTTP headers in every
Expand All @@ -137,23 +166,30 @@ fun HttpRequest.withDebugHeaders(): HttpRequest =
* - X-Correlation-ID: Every request/response SHALL contain a unique correlation ID, every response to this request
* SHALL contain the same ID.
*
* Moreover, for requests, Content-Type SHALL be set to application/json for any request that contains a
* message body: POST, PUT and PATCH. When no body is present, probably in a GET or DELETE, then the Content-Type
* header MAY be omitted.
*
* This method should be called when doing the a request from a server.
*
* TODO: test debug headers with integration tests
*
* @param headers Headers of the caller. It will re-use the X-Correlation-ID header and regenerate X-Request-ID
*/
fun HttpRequest.withUpdatedDebugHeaders(headers: Map<String, String>): HttpRequest =
fun HttpRequest.withUpdatedRequiredHeaders(
headers: Map<String, String>,
generatedRequestId: String
): HttpRequest =
withHeaders(
headers = headers
.plus("X-Request-ID" to generateUUIDv4Token())
.plus(Header.X_REQUEST_ID to generatedRequestId) // it replaces existing X_REQUEST_ID header
.plus(
"X-Correlation-ID" to headers.getOrDefault(
"X-Correlation-ID",
"error - could not get X-Correlation-ID header"
)
Header.X_CORRELATION_ID to (
headers.getByNormalizedKey(Header.X_CORRELATION_ID)
?: "error - could not get ${Header.X_CORRELATION_ID} header"
)
)
)
).withContentTypeHeaderIfNeeded()

/**
* For debugging issues, OCPI implementations are required to include unique IDs via HTTP headers in every
Expand All @@ -167,24 +203,42 @@ fun HttpRequest.withUpdatedDebugHeaders(headers: Map<String, String>): HttpReque
* This method should be called when responding to a request from a client.
*/
fun HttpRequest.getDebugHeaders() = listOfNotNull(
headers["X-Request-ID"]?.let { "X-Request-ID" to it },
headers["X-Correlation-ID"]?.let { "X-Correlation-ID" to it }
getHeader(Header.X_REQUEST_ID)?.let { Header.X_REQUEST_ID to it },
getHeader(Header.X_CORRELATION_ID)?.let { Header.X_CORRELATION_ID to it }
).toMap()

/**
* Returns the value of a header by its key. The key is not case-sensitive.
*/
fun HttpRequest.getHeader(key: String): String? =
headers.getByNormalizedKey(key)

/**
* Returns the value of a header by its key. The key is not case-sensitive.
*/
fun HttpResponse.getHeader(key: String): String? =
headers.getByNormalizedKey(key)

/**
* Returns the value of a map entry by its key. The key is not case-sensitive.
*/
fun Map<String, String>.getByNormalizedKey(key: String): String? =
mapKeys { it.key.lowercase() }[key.lowercase()]

/**
* Parses authorization header from the HttpRequest
*
* @throws OcpiClientNotEnoughInformationException if the token is missing
* @throws HttpException if the authorization header is missing
*/
fun HttpRequest.parseAuthorizationHeader() = (headers["Authorization"] ?: headers["authorization"])
fun HttpRequest.parseAuthorizationHeader() = getHeader(Header.AUTHORIZATION)
?.let {
if (it.startsWith("Token ")) it
else throw OcpiClientInvalidParametersException("Unkown token format: $it")
}
?.removePrefix("Token ")
?.decodeBase64()
?: throw HttpException(HttpStatus.UNAUTHORIZED, "Authorization header missing")
?: throw HttpException(HttpStatus.UNAUTHORIZED, "${Header.AUTHORIZATION} header missing")

/**
* Throws an exception if the token is invalid. Does nothing otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ suspend fun <T> HttpRequest.httpResponse(fn: suspend () -> OcpiResponseBody<T>):
}
),
headers = getDebugHeaders()
.plus(Header.CONTENT_TYPE to ContentType.APPLICATION_JSON)
).let {
if (isPaginated) {
it.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,54 @@ import com.izivia.ocpi.toolkit.transport.domain.HttpRequest
*/
class CredentialsClient(
private val transportClient: TransportClient
): CredentialsInterface {
) : CredentialsInterface {

override suspend fun get(tokenC: String): OcpiResponseBody<Credentials> =
transportClient
.send(
HttpRequest(method = HttpMethod.GET)
.withDebugHeaders()
.withRequiredHeaders(
requestId = transportClient.generateRequestId(),
correlationId = transportClient.generateCorrelationId()
)
lilgallon marked this conversation as resolved.
Show resolved Hide resolved
.authenticate(token = tokenC)
)
.parseBody()


override suspend fun post(tokenA: String, credentials: Credentials, debugHeaders: Map<String, String>): OcpiResponseBody<Credentials> =
override suspend fun post(
tokenA: String,
credentials: Credentials,
debugHeaders: Map<String, String>
): OcpiResponseBody<Credentials> =
transportClient
.send(
HttpRequest(
method = HttpMethod.POST,
body = mapper.writeValueAsString(credentials)
)
.withDebugHeaders()
.withRequiredHeaders(
requestId = transportClient.generateRequestId(),
correlationId = transportClient.generateCorrelationId()
)
.authenticate(token = tokenA)
)
.parseBody()

override suspend fun put(tokenC: String, credentials: Credentials, debugHeaders: Map<String, String>): OcpiResponseBody<Credentials> =
override suspend fun put(
tokenC: String,
credentials: Credentials,
debugHeaders: Map<String, String>
): OcpiResponseBody<Credentials> =
transportClient
.send(
HttpRequest(
method = HttpMethod.PUT,
body = mapper.writeValueAsString(credentials)
)
.withDebugHeaders()
.withRequiredHeaders(
requestId = transportClient.generateRequestId(),
correlationId = transportClient.generateCorrelationId()
)
.authenticate(token = tokenC)
)
.parseBody()
Expand All @@ -52,7 +68,10 @@ class CredentialsClient(
transportClient
.send(
HttpRequest(method = HttpMethod.DELETE)
.withDebugHeaders()
.withRequiredHeaders(
requestId = transportClient.generateRequestId(),
correlationId = transportClient.generateCorrelationId()
)
.authenticate(token = tokenC)
)
.parseBody()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,16 @@ class CredentialsServerService(
) {
val versions = transportClientBuilder
.build(credentials.url)
.send(
HttpRequest(method = HttpMethod.GET)
.withUpdatedDebugHeaders(headers = debugHeaders)
.authenticate(token = credentials.token)
)
.run {
send(
HttpRequest(method = HttpMethod.GET)
.withUpdatedRequiredHeaders(
headers = debugHeaders,
generatedRequestId = generateRequestId()
)
.authenticate(token = credentials.token)
)
}
.parseBody<OcpiResponseBody<List<Version>>>()
.let {
it.data
Expand All @@ -124,11 +129,16 @@ class CredentialsServerService(

val versionDetail = transportClientBuilder
.build(matchingVersion.url)
.send(
HttpRequest(method = HttpMethod.GET)
.withUpdatedDebugHeaders(headers = debugHeaders)
.authenticate(token = credentials.token)
)
.run {
send(
HttpRequest(method = HttpMethod.GET)
.withUpdatedRequiredHeaders(
headers = debugHeaders,
generatedRequestId = generateRequestId()
)
.authenticate(token = credentials.token)
)
}
.parseBody<OcpiResponseBody<VersionDetails>>()
.let {
it.data
Expand Down
Loading