IdAHandler1.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.admin_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.admin_client.api.IdAClientConfiguration;
import com.io7m.idstore.admin_client.api.IdAClientCredentials;
import com.io7m.idstore.admin_client.api.IdAClientEventType;
import com.io7m.idstore.admin_client.api.IdAClientException;
import com.io7m.idstore.error_codes.IdStandardErrorCodes;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdVersion;
import com.io7m.idstore.protocol.admin.IdACommandLogin;
import com.io7m.idstore.protocol.admin.IdACommandType;
import com.io7m.idstore.protocol.admin.IdAMessageType;
import com.io7m.idstore.protocol.admin.IdAResponseError;
import com.io7m.idstore.protocol.admin.IdAResponseLogin;
import com.io7m.idstore.protocol.admin.IdAResponseType;
import com.io7m.idstore.protocol.admin.cb.IdACB1Messages;
import com.io7m.idstore.protocol.api.IdProtocolException;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.junreachable.UnreachableCodeException;
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.admin_client.internal.IdACompression.decompressResponse;
import static com.io7m.idstore.admin_client.internal.IdAUUIDs.nullUUID;
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.admin.IdAResponseBlame.BLAME_CLIENT;
import static com.io7m.idstore.protocol.admin.IdAResponseBlame.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 java.util.Objects.requireNonNullElse;
/**
* The version 1 protocol handler.
*/
public final class IdAHandler1 extends IdAHandlerAbstract
{
private static final Logger LOG =
LoggerFactory.getLogger(IdAHandler1.class);
private final IdACB1Messages messages;
private final URI loginURI;
private final URI commandURI;
private IdACommandLogin 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
*/
IdAHandler1(
final IdAClientConfiguration inConfiguration,
final IdStrings inStrings,
final HttpClient inHttpClient,
final URI baseURI)
{
super(inConfiguration, inStrings, inHttpClient);
this.messages =
new IdACB1Messages();
this.loginURI =
baseURI.resolve("login")
.normalize();
this.commandURI =
baseURI.resolve("command")
.normalize();
}
private static boolean isAuthenticationError(
final IdAResponseError 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 IdAResponseType, C extends IdACommandType<R>>
HBResultType<R, IdAResponseError>
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 var request =
HttpRequest.newBuilder(uri)
.header("User-Agent", userAgent())
.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 = IdACB1Messages.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 IdAResponseType 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 IdAResponseError 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.
*/
LOG.debug("server: response {}", responseActual.getClass());
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 IdAResponseError(
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 IdAResponseError(
nullUUID(),
requireNonNullElse(
e.getMessage(),
this.local(CONNECT_FAILURE)
),
IO_ERROR,
Map.of(),
Optional.empty(),
BLAME_CLIENT
)
);
}
}
private <R extends IdAResponseType, C extends IdACommandType<R>> HBResultType<R, IdAResponseError>
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<IdAResponseLogin, IdAResponseError>) {
return this.send(
attempt + 1,
uri,
false,
message
);
}
if (loginResponse instanceof final HBResultFailure<IdAResponseLogin, IdAResponseError> failure) {
return failure.cast();
}
throw new UnreachableCodeException();
}
private HBResultType<IdAResponseLogin, IdAResponseError> sendLogin(
final IdACommandLogin login)
throws InterruptedException
{
return this.send(1, this.loginURI, true, login);
}
private <R extends IdAResponseType> HBResultFailure<R, IdAResponseError> 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 IdAResponseError(
nullUUID(),
this.local(ERROR_UNEXPECTED_CONTENT_TYPE),
PROTOCOL_ERROR,
attributes,
Optional.empty(),
BLAME_SERVER
)
);
}
private <R extends IdAResponseType, C extends IdACommandType<R>>
HBResultFailure<R, IdAResponseError>
errorUnexpectedResponseType(
final C message,
final IdAMessageType 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 IdAResponseError(
nullUUID(),
this.local(ERROR_UNEXPECTED_RESPONSE_TYPE),
PROTOCOL_ERROR,
attributes,
Optional.empty(),
BLAME_SERVER
)
);
}
@Override
public boolean onIsConnected()
{
return true;
}
@Override
public List<IdAClientEventType> onPollEvents()
{
return List.of();
}
@Override
public HBResultType<
HBClientNewHandler<
IdAClientException,
IdACommandType<?>,
IdAResponseType,
IdAResponseType,
IdAResponseError,
IdAClientEventType,
IdAClientCredentials>,
IdAResponseError>
onExecuteLogin(
final IdAClientCredentials credentials)
throws InterruptedException
{
LOG.debug("login: {}", credentials.baseURI());
this.mostRecentLogin =
new IdACommandLogin(
new IdName(credentials.userName()),
credentials.password(),
credentials.attributes()
);
final var response =
this.sendLogin(this.mostRecentLogin);
if (response instanceof final HBResultSuccess<IdAResponseLogin, IdAResponseError> success) {
LOG.debug("login: succeeded");
return new HBResultSuccess<>(
new HBClientNewHandler<>(this, success.result())
);
}
if (response instanceof final HBResultFailure<IdAResponseLogin, IdAResponseError> failure) {
LOG.debug("login: failed ({})", failure.result().message());
return failure.cast();
}
throw new UnreachableCodeException();
}
@Override
public HBResultType<IdAResponseType, IdAResponseError>
onExecuteCommand(
final IdACommandType<?> 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(
"[IdAHandler1 0x%08x]",
Integer.valueOf(this.hashCode())
);
}
}