RekorV2ClientHttp.java

/*
 * Copyright 2025 The Sigstore Authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package dev.sigstore.rekor.v2.client;

import com.google.api.client.http.ByteArrayContent;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.util.Preconditions;
import com.google.gson.reflect.TypeToken;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.util.JsonFormat;
import dev.sigstore.http.HttpClients;
import dev.sigstore.http.HttpParams;
import dev.sigstore.http.ImmutableHttpParams;
import dev.sigstore.json.GsonSupplier;
import dev.sigstore.json.ProtoJson;
import dev.sigstore.proto.rekor.v1.TransparencyLogEntry;
import dev.sigstore.proto.rekor.v2.HashedRekordRequestV002;
import dev.sigstore.rekor.client.RekorParseException;
import dev.sigstore.trustroot.Service;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

/** A client to communicate with a rekor v2 service instance over http. */
public class RekorV2ClientHttp implements RekorV2Client {
  public static final String REKOR_ENTRIES_PATH = "/api/v2/log/entries";
  public static final String REKOR_CHECKPOINT_PATH = "/api/v2/checkpoint";

  private final HttpParams httpParams;
  private final URI uri;

  public static RekorV2ClientHttp.Builder builder() {
    return new RekorV2ClientHttp.Builder();
  }

  private RekorV2ClientHttp(HttpParams httpParams, URI uri) {
    this.uri = uri;
    this.httpParams = httpParams;
  }

  public static class Builder {
    private HttpParams httpParams = ImmutableHttpParams.builder().build();
    private Service service;

    private Builder() {}

    /** Configure the http properties, see {@link HttpParams}, {@link ImmutableHttpParams}. */
    public Builder setHttpParams(HttpParams httpParams) {
      this.httpParams = httpParams;
      return this;
    }

    /** Service information for a remote rekor instance. */
    public Builder setService(Service service) {
      this.service = service;
      return this;
    }

    public RekorV2ClientHttp build() {
      Preconditions.checkNotNull(service);
      return new RekorV2ClientHttp(httpParams, service.getUrl());
    }
  }

  @Override
  public TransparencyLogEntry putEntry(HashedRekordRequestV002 hashedRekordRequest)
      throws IOException, RekorParseException {
    URI rekorPutEndpoint = uri.resolve(REKOR_ENTRIES_PATH);

    String jsonPayload;
    try {
      String innerJson = JsonFormat.printer().print(hashedRekordRequest);

      Type type = new TypeToken<Map<String, Object>>() {}.getType();
      Map<String, Object> innerMap = GsonSupplier.GSON.get().fromJson(innerJson, type);

      var requestMap = new HashMap<String, Object>();
      requestMap.put("hashedRekordRequestV002", innerMap);

      jsonPayload = GsonSupplier.GSON.get().toJson(requestMap);
    } catch (InvalidProtocolBufferException e) {
      throw new RekorParseException("Failed to serialize HashedRekordRequestV002 to JSON", e);
    }

    HttpRequest req =
        HttpClients.newRequestFactory(httpParams)
            .buildPostRequest(
                new GenericUrl(rekorPutEndpoint),
                ByteArrayContent.fromString("application/json", jsonPayload));
    req.getHeaders().set("Accept", "application/json");
    req.getHeaders().set("Content-Type", "application/json");

    HttpResponse resp = req.execute();
    if (resp.getStatusCode() != 201) {
      throw new IOException(
          String.format(
              Locale.ROOT,
              "bad response from rekor @ '%s' : %s",
              rekorPutEndpoint,
              resp.parseAsString()));
    }

    String respEntryJson = resp.parseAsString();

    try {
      TransparencyLogEntry.Builder builder = TransparencyLogEntry.newBuilder();
      ProtoJson.parser().merge(respEntryJson, builder);
      return builder.build();
    } catch (InvalidProtocolBufferException e) {
      throw new RekorParseException("Failed to parse Rekor response JSON", e);
    }
  }
}