Add Profiles OpenAPI annotations

This commit is contained in:
Chris Eager
2026-03-13 18:20:18 -04:00
committed by Jon Chambers
parent b7d455ed11
commit 136cddf6e2
9 changed files with 154 additions and 66 deletions
@@ -11,6 +11,10 @@ import com.google.common.base.Preconditions;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
@@ -72,7 +76,6 @@ import org.whispersystems.textsecuregcm.entities.BaseProfileResponse;
import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest;
import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse;
import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
@@ -151,6 +154,18 @@ public class ProfileController {
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Update profile",
description = "Updates an accounts profile. Must be authenticated.")
@ApiResponse(responseCode = "200", description = "The profile was updated successfully.",
content = @Content(schema = @Schema(
implementation = ProfileAvatarUploadAttributes.class,
description = "If the request changed the avatar, the response body contains an upload form.")))
@ApiResponse(responseCode = "400", description = "Invalid create profile request.")
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "403", description = "The request contained a payment address, but payments are not supported in the region of the accounts phone number.")
@ApiResponse(responseCode = "412", description = "The requesting account has the profiles_v2 capability")
@ApiResponse(responseCode = "422", description = "Invalid request format")
public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
@@ -214,8 +229,16 @@ public class ProfileController {
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{identifier}/{version}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get versioned profile",
description = "Retrieves a specific version of an account's profile. Requires either authentication or an unidentified access key.")
@ApiResponse(responseCode = "200", description = "Profile retrieved successfully.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Malformed identifier")
@ApiResponse(responseCode = "401", description = "Not authorized to access this profile.")
@ApiResponse(responseCode = "404", description = "Profile or account not found.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
@ManagedAsync
public VersionedProfileResponse getProfile(
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@@ -241,9 +264,21 @@ public class ProfileController {
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{identifier}/{version}/{credentialRequest}")
public CredentialProfileResponse getProfile(
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get profile with credential",
description = "Retrieves a specific version of an account's profile along with an expiring profile key credential. Requires either authentication or an unidentified access key."
)
@ApiResponse(
responseCode = "200",
description = "Account found. Profile information will be limited and credential will be null if the version was not found",
useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Invalid credential type or credential request.")
@ApiResponse(responseCode = "401", description = "Not authorized to access this profile.")
@ApiResponse(responseCode = "404", description = "Profile or account not found.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
public ExpiringProfileKeyCredentialProfileResponse getProfile(
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@Context ContainerRequestContext containerRequestContext,
@@ -276,8 +311,16 @@ public class ProfileController {
// Although clients should generally be using versioned profiles wherever possible, there are still a few lingering
// use cases for getting profiles without a version (e.g. getting a contact's unidentified access key checksum).
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{identifier}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get unversioned profile",
description = "Retrieves basic profile information without a specific version. Supports ACI and PNI identifiers. Requires authentication, an unidentified access key, or a group send token.")
@ApiResponse(responseCode = "200", description = "Unversioned profile retrieved successfully.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Invalid request (e.g., multiple authorization types provided).")
@ApiResponse(responseCode = "401", description = "Not authorized to access this profile.")
@ApiResponse(responseCode = "404", description = "Account not found.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
@ManagedAsync
public BaseProfileResponse getUnversionedProfile(
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@@ -318,9 +361,17 @@ public class ProfileController {
}
@POST
@Path("/identity_check/batch")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("/identity_check/batch")
@Operation(
summary = "Batch identity key check",
description = "Checks identity key fingerprints for multiple accounts. Returns accounts where the fingerprint does not match. Should not be authenticated.")
@ApiResponse(
responseCode = "200",
description = "Batch check completed successfully. Response may contain accounts with mismatched fingerprints.",
content = @Content(schema = @Schema(implementation = BatchIdentityCheckResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid request format or validation failed.")
public CompletableFuture<BatchIdentityCheckResponse> runBatchIdentityCheck(@NotNull @Valid BatchIdentityCheckRequest request) {
return CompletableFuture.supplyAsync(() -> {
List<BatchIdentityCheckResponse.Element> responseElements = Collections.synchronizedList(new ArrayList<>());
@@ -410,7 +461,7 @@ public class ProfileController {
final Optional<VersionedProfile> maybeProfile = profilesManager.get(account.getUuid(), version);
if (maybeProfile.isEmpty()) {
// Hypothesis: this should basically never happen since clients can't delete versions
// this can happen if an account re-registers, which includes some device-transfer scenarios
Metrics.counter(
VERSION_NOT_FOUND_COUNTER_NAME,
Tags.of(
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
@@ -17,27 +18,34 @@ import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
import java.util.List;
import java.util.Map;
@Schema(description = "Unversioned profile containing basic information")
public class BaseProfileResponse {
@Schema(description = "The account's public identity key (unpadded base64)")
@JsonProperty
@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)
@JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)
private IdentityKey identityKey;
@Schema(description = "Checksum for unidentified access authentication (padded base64)")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
private byte[] unidentifiedAccess;
@Schema(description = "Whether unidentified access is unrestricted for this account")
@JsonProperty
private boolean unrestrictedUnidentifiedAccess;
@Schema(description = "Device capabilities and whether they are enabled")
@JsonProperty
private Map<String, Boolean> capabilities;
@Schema(description = "List of badges displayed on the profile")
@JsonProperty
private List<Badge> badges;
@Schema(description = "Service identifier (ACI or PNI)")
@JsonProperty
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
@@ -7,29 +7,36 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.util.List;
import javax.annotation.Nullable;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
public record BatchIdentityCheckRequest(@Valid @NotNull @Size(max = 1000) List<Element> elements) {
@Schema(description = "Request to check identity key fingerprints for multiple accounts")
public record BatchIdentityCheckRequest(
@Schema(description = "List of accounts to check")
@Valid @NotNull @Size(max = 1000) List<Element> elements) {
/**
* @param uuid account id or phone number id
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public
* key prefixed with 0x05)
*/
public record Element(@NotNull
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
ServiceIdentifier uuid,
@Schema(description = "A service identifier and expected identity key fingerprint")
public record Element(
@Schema(description = "Identifier (ACI or PNI)")
@NotNull
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
ServiceIdentifier uuid,
@NotNull
@ExactlySize(4)
byte[] fingerprint) {
}
@Schema(description = "Expected identity key fingerprint (4 bytes, most significant bytes of SHA-256 hash of 33-byte identity key field)")
@NotNull
@ExactlySize(4)
byte[] fingerprint) {
}
}
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
@@ -16,17 +17,24 @@ import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
public record BatchIdentityCheckResponse(@Valid List<Element> elements) {
@Schema(description = "Response containing accounts with mismatched identity key fingerprints")
public record BatchIdentityCheckResponse(
@Schema(description = "List of accounts where fingerprint did not match (empty if all matched)")
@Valid List<Element> elements) {
public record Element(@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
@NotNull
ServiceIdentifier uuid,
@Schema(description = "An account with a mismatched identity key fingerprint")
public record Element(
@Schema(description = "Service identifier (ACI or PNI)")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
@NotNull
ServiceIdentifier uuid,
@NotNull
@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)
@JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)
IdentityKey identityKey) {
@Schema(description = "The actual identity key for this account")
@NotNull
@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)
@JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)
IdentityKey identityKey) {
}
}
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Optional;
import jakarta.validation.constraints.NotEmpty;
@@ -17,52 +18,63 @@ import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.ValidHexString;
@Schema(description = "Request to create or update a versioned profile")
public record CreateProfileRequest(
@Schema(description = "Profile key commitment")
@JsonProperty
@NotNull
@JsonDeserialize(using = ProfileKeyCommitmentAdapter.Deserializing.class)
@JsonSerialize(using = ProfileKeyCommitmentAdapter.Serializing.class)
ProfileKeyCommitment commitment,
@Schema(description = "Profile version identifier (hex-encoding of the public key)")
@JsonProperty
@NotEmpty
@ValidHexString
@ExactlySize({64})
String version,
@Schema(description = "Encrypted profile name. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
@ExactlySize({81, 285})
byte[] name,
@Schema(description = "Encrypted about emoji. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
@ExactlySize({0, 60})
byte[] aboutEmoji,
@Schema(description = "Encrypted about text. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
@ExactlySize({0, 156, 282, 540})
byte[] about,
@Schema(description = "Encrypted payment address. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
@ExactlySize({0, 582})
byte[] paymentAddress,
@Schema(description = "Whether the profile has an avatar")
@JsonProperty("avatar")
boolean hasAvatar,
@Schema(description = "Whether the avatar is unchanged from the previous version")
@JsonProperty
boolean sameAvatar,
@Schema(description = "List of badge IDs to display on the profile")
@JsonProperty("badgeIds")
Optional<List<String>> badges,
@Schema(description = "Encrypted phone number sharing preference. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
@@ -1,25 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
public abstract class CredentialProfileResponse {
@JsonUnwrapped
private VersionedProfileResponse versionedProfileResponse;
protected CredentialProfileResponse() {
}
protected CredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse) {
this.versionedProfileResponse = versionedProfileResponse;
}
public VersionedProfileResponse getVersionedProfileResponse() {
return versionedProfileResponse;
}
}
@@ -6,13 +6,20 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.annotation.Nullable;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;
public class ExpiringProfileKeyCredentialProfileResponse extends CredentialProfileResponse {
@Schema(description = "Profile response with an expiring profile key credential")
public class ExpiringProfileKeyCredentialProfileResponse {
@JsonUnwrapped
private VersionedProfileResponse versionedProfileResponse;
@Schema(description = "Expiring profile key credential response. Null if profile version was not found")
@JsonProperty
@JsonSerialize(using = ExpiringProfileKeyCredentialResponseAdapter.Serializing.class)
@JsonDeserialize(using = ExpiringProfileKeyCredentialResponseAdapter.Deserializing.class)
@@ -25,7 +32,7 @@ public class ExpiringProfileKeyCredentialProfileResponse extends CredentialProfi
public ExpiringProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse,
@Nullable final ExpiringProfileKeyCredentialResponse credential) {
super(versionedProfileResponse);
this.versionedProfileResponse = versionedProfileResponse;
this.credential = credential;
}
@@ -33,4 +40,8 @@ public class ExpiringProfileKeyCredentialProfileResponse extends CredentialProfi
public ExpiringProfileKeyCredentialResponse getCredential() {
return credential;
}
public VersionedProfileResponse getVersionedProfileResponse() {
return versionedProfileResponse;
}
}
@@ -6,44 +6,51 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Profile avatar upload form (S3 Post Policy, AWS Signature version 4)")
public class ProfileAvatarUploadAttributes {
@Schema(description = "Object key for the avatar")
@JsonProperty
private String key;
@Schema(description = "Credential for the upload")
@JsonProperty
private String credential;
@Schema(description = "Access control list setting")
@JsonProperty
private String acl;
@Schema(description = "Signing algorithm")
@JsonProperty
private String algorithm;
@Schema(description = "Date in AWS format")
@JsonProperty
private String date;
@Schema(description = "Base64-encoded upload policy")
@JsonProperty
private String policy;
@Schema(description = "Signature calculated over the policy")
@JsonProperty
private String signature;
public ProfileAvatarUploadAttributes() {}
public ProfileAvatarUploadAttributes(String key, String credential,
String acl, String algorithm,
String date, String policy,
String signature)
{
this.key = key;
public ProfileAvatarUploadAttributes(String key, String credential, String acl, String algorithm, String date,
String policy, String signature) {
this.key = key;
this.credential = credential;
this.acl = acl;
this.algorithm = algorithm;
this.date = date;
this.policy = policy;
this.signature = signature;
this.acl = acl;
this.algorithm = algorithm;
this.date = date;
this.policy = policy;
this.signature = signature;
}
public String getKey() {
@@ -9,36 +9,45 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
@Schema(description = "Versioned profile containing encrypted fields. Versioned fields may be empty if the version was not found.")
public record VersionedProfileResponse(
@Schema(description = "Base profile information")
@JsonUnwrapped
BaseProfileResponse baseProfileResponse,
@Schema(description = "Encrypted profile name. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
byte[] name,
@Schema(description = "Encrypted about text. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
byte[] about,
@Schema(description = "Encrypted about emoji. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
byte[] aboutEmoji,
@Schema(description = "Avatar CDN path")
@JsonProperty
String avatar,
@Schema(description = "Encrypted payment address. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
byte[] paymentAddress,
@Schema(description = "Encrypted phone number sharing preference. Padded base64.")
@JsonProperty
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)