IdUHandler1.java
/*
* Copyright © 2023 Mark Raynsford <code@io7m.com> https://www.io7m.com
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
* SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
* IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package com.io7m.idstore.user_client.internal;
import com.io7m.hibiscus.api.HBResultFailure;
import com.io7m.hibiscus.api.HBResultSuccess;
import com.io7m.hibiscus.api.HBResultType;
import com.io7m.hibiscus.basic.HBClientNewHandler;
import com.io7m.idstore.error_codes.IdStandardErrorCodes;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdVersion;
import com.io7m.idstore.protocol.api.IdProtocolException;
import com.io7m.idstore.protocol.user.IdUCommandLogin;
import com.io7m.idstore.protocol.user.IdUCommandType;
import com.io7m.idstore.protocol.user.IdUMessageType;
import com.io7m.idstore.protocol.user.IdUResponseError;
import com.io7m.idstore.protocol.user.IdUResponseLogin;
import com.io7m.idstore.protocol.user.IdUResponseType;
import com.io7m.idstore.protocol.user.cb.IdUCB1Messages;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.idstore.user_client.api.IdUClientConfiguration;
import com.io7m.idstore.user_client.api.IdUClientCredentials;
import com.io7m.idstore.user_client.api.IdUClientEventType;
import com.io7m.idstore.user_client.api.IdUClientException;
import com.io7m.junreachable.UnreachableCodeException;
import io.opentelemetry.context.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.IO_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.PROTOCOL_ERROR;
import static com.io7m.idstore.protocol.user.IdUResponseBlame.BLAME_CLIENT;
import static com.io7m.idstore.protocol.user.IdUResponseBlame.BLAME_SERVER;
import static com.io7m.idstore.strings.IdStringConstants.CONNECT_FAILURE;
import static com.io7m.idstore.strings.IdStringConstants.ERROR_UNEXPECTED_CONTENT_TYPE;
import static com.io7m.idstore.strings.IdStringConstants.ERROR_UNEXPECTED_RESPONSE_TYPE;
import static com.io7m.idstore.strings.IdStringConstants.EXPECTED_CONTENT_TYPE;
import static com.io7m.idstore.strings.IdStringConstants.EXPECTED_RESPONSE_TYPE;
import static com.io7m.idstore.strings.IdStringConstants.RECEIVED_CONTENT_TYPE;
import static com.io7m.idstore.strings.IdStringConstants.RECEIVED_RESPONSE_TYPE;
import static com.io7m.idstore.user_client.internal.IdUCompression.decompressResponse;
import static com.io7m.idstore.user_client.internal.IdUUUIDs.nullUUID;
import static java.util.Objects.requireNonNullElse;
/**
* The version 1 protocol handler.
*/
public final class IdUHandler1 extends IdUHandlerAbstract
{
private static final Logger LOG =
LoggerFactory.getLogger(IdUHandler1.class);
private final IdUCB1Messages messages;
private final URI loginURI;
private final URI commandURI;
private final boolean connected;
private IdUCommandLogin mostRecentLogin;
/**
* The protocol 1 handler.
*
* @param inConfiguration The client configuration
* @param inStrings String resources
* @param inHttpClient The HTTP client
* @param baseURI The base URI returned by the server during version
* negotiation
*/
IdUHandler1(
final IdUClientConfiguration inConfiguration,
final IdStrings inStrings,
final HttpClient inHttpClient,
final URI baseURI)
{
super(inConfiguration, inStrings, inHttpClient);
this.messages =
new IdUCB1Messages();
this.loginURI =
baseURI.resolve("login")
.normalize();
this.commandURI =
baseURI.resolve("command")
.normalize();
this.connected = false;
}
private static boolean isAuthenticationError(
final IdUResponseError error)
{
return Objects.equals(
error.errorCode(),
IdStandardErrorCodes.AUTHENTICATION_ERROR
);
}
private static String userAgent()
{
return "com.io7m.idstore.client/%s (%s)"
.formatted(IdVersion.MAIN_VERSION, IdVersion.MAIN_BUILD);
}
private <R extends IdUResponseType, C extends IdUCommandType<R>>
HBResultType<R, IdUResponseError>
send(
final int attempt,
final URI uri,
final boolean isLoggingIn,
final C message)
throws InterruptedException
{
try {
final var commandType = message.getClass().getSimpleName();
LOG.debug("sending {} to {}", commandType, uri);
final var sendBytes =
this.messages.serialize(message);
final HttpRequest.Builder builder =
HttpRequest.newBuilder(uri)
.header("User-Agent", userAgent());
/*
* Inject any required trace propagation headers.
*/
this.configuration()
.openTelemetry()
.getPropagators()
.getTextMapPropagator()
.inject(Context.current(), builder, (b, name, value) -> {
if (LOG.isTraceEnabled()) {
LOG.trace("injecting header {} -> {}", name, value);
}
builder.header(name, value);
});
final var request =
builder.POST(HttpRequest.BodyPublishers.ofByteArray(sendBytes))
.build();
final var response =
this.httpClient()
.send(request, HttpResponse.BodyHandlers.ofByteArray());
LOG.debug("server: status {}", response.statusCode());
final var responseHeaders =
response.headers();
/*
* Check the content type. Fail if it's not what we expected.
*/
final var contentType =
responseHeaders.firstValue("content-type")
.orElse("application/octet-stream");
final var expectedContentType = IdUCB1Messages.contentType();
if (!contentType.equals(expectedContentType)) {
return this.errorContentType(contentType, expectedContentType);
}
/*
* Parse the response message, decompressing if necessary. If the
* parsed message isn't a response... fail.
*/
final var responseMessage =
this.messages.parse(decompressResponse(response, responseHeaders));
if (!(responseMessage instanceof final IdUResponseType responseActual)) {
return this.errorUnexpectedResponseType(message, responseMessage);
}
/*
* If the response is an error, then perhaps retry. We only attempt
* to retry if the response indicates an authentication error; if this
* happens, we try to log in again and then re-send the original message.
*
* We don't try to blanket re-send any message that "failed" because
* messages might have side effects on the server.
*/
if (responseActual instanceof final IdUResponseError error) {
if (attempt < 3) {
if (isAuthenticationError(error) && !isLoggingIn) {
return this.reLoginAndSend(attempt, uri, message);
}
}
return new HBResultFailure<>(error);
}
/*
* We know that the response is an error, but we don't know that the
* response is of the expected type. Check that here, and fail if it
* isn't.
*/
if (!Objects.equals(responseActual.getClass(), message.responseClass())) {
return this.errorUnexpectedResponseType(message, responseActual);
}
return new HBResultSuccess<>(
message.responseClass().cast(responseMessage)
);
} catch (final IdProtocolException e) {
LOG.debug("protocol exception: ", e);
return new HBResultFailure<>(
new IdUResponseError(
nullUUID(),
e.message(),
e.errorCode(),
e.attributes(),
Optional.empty(),
BLAME_SERVER
)
);
} catch (final IOException e) {
LOG.debug("i/o exception: ", e);
return new HBResultFailure<>(
new IdUResponseError(
nullUUID(),
requireNonNullElse(
e.getMessage(),
this.local(CONNECT_FAILURE)
),
IO_ERROR,
Map.of(),
Optional.empty(),
BLAME_CLIENT
)
);
}
}
private <R extends IdUResponseType, C extends IdUCommandType<R>> HBResultType<R, IdUResponseError>
reLoginAndSend(
final int attempt,
final URI uri,
final C message)
throws InterruptedException
{
LOG.debug("attempting re-login");
final var loginResponse =
this.sendLogin(this.mostRecentLogin);
if (loginResponse instanceof HBResultSuccess<IdUResponseLogin, IdUResponseError>) {
return this.send(
attempt + 1,
uri,
false,
message
);
}
if (loginResponse instanceof final HBResultFailure<IdUResponseLogin, IdUResponseError> failure) {
return failure.cast();
}
throw new UnreachableCodeException();
}
private HBResultType<IdUResponseLogin, IdUResponseError> sendLogin(
final IdUCommandLogin login)
throws InterruptedException
{
return this.send(1, this.loginURI, true, login);
}
private <R extends IdUResponseType> HBResultFailure<R, IdUResponseError> errorContentType(
final String contentType,
final String expectedContentType)
{
final var attributes = new HashMap<String, String>();
attributes.put(
this.local(EXPECTED_CONTENT_TYPE),
expectedContentType
);
attributes.put(
this.local(RECEIVED_CONTENT_TYPE),
contentType
);
return new HBResultFailure<>(
new IdUResponseError(
nullUUID(),
this.local(ERROR_UNEXPECTED_CONTENT_TYPE),
PROTOCOL_ERROR,
attributes,
Optional.empty(),
BLAME_SERVER
)
);
}
private <R extends IdUResponseType, C extends IdUCommandType<R>>
HBResultFailure<R, IdUResponseError>
errorUnexpectedResponseType(
final C message,
final IdUMessageType responseActual)
{
final var attributes = new HashMap<String, String>();
attributes.put(
this.local(EXPECTED_RESPONSE_TYPE),
message.responseClass().getSimpleName()
);
attributes.put(
this.local(RECEIVED_RESPONSE_TYPE),
responseActual.getClass().getSimpleName()
);
return new HBResultFailure<>(
new IdUResponseError(
nullUUID(),
this.local(ERROR_UNEXPECTED_RESPONSE_TYPE),
PROTOCOL_ERROR,
attributes,
Optional.empty(),
BLAME_SERVER
)
);
}
@Override
public boolean onIsConnected()
{
return this.connected;
}
@Override
public List<IdUClientEventType> onPollEvents()
{
return List.of();
}
@Override
public HBResultType<
HBClientNewHandler<
IdUClientException,
IdUCommandType<?>,
IdUResponseType,
IdUResponseType,
IdUResponseError,
IdUClientEventType,
IdUClientCredentials>,
IdUResponseError>
onExecuteLogin(
final IdUClientCredentials credentials)
throws InterruptedException
{
LOG.debug("login: {}", credentials.baseURI());
this.mostRecentLogin =
new IdUCommandLogin(
new IdName(credentials.userName()),
credentials.password(),
credentials.attributes()
);
final var response =
this.sendLogin(this.mostRecentLogin);
if (response instanceof final HBResultSuccess<IdUResponseLogin, IdUResponseError> success) {
LOG.debug("login: succeeded");
return new HBResultSuccess<>(
new HBClientNewHandler<>(this, success.result())
);
}
if (response instanceof final HBResultFailure<IdUResponseLogin, IdUResponseError> failure) {
LOG.debug("login: failed ({})", failure.result().message());
return failure.cast();
}
throw new UnreachableCodeException();
}
@Override
public HBResultType<IdUResponseType, IdUResponseError>
onExecuteCommand(
final IdUCommandType<?> command)
throws InterruptedException
{
return this.send(1, this.commandURI, false, command)
.map(x -> x);
}
@Override
public void onDisconnect()
{
}
@Override
public String toString()
{
return String.format(
"[IdUHandler1 0x%08x]",
Integer.valueOf(this.hashCode())
);
}
}