1212import static io .a2a .spec .A2AErrorCodes .TASK_NOT_CANCELABLE_ERROR_CODE ;
1313import static io .a2a .spec .A2AErrorCodes .TASK_NOT_FOUND_ERROR_CODE ;
1414import static io .a2a .spec .A2AErrorCodes .UNSUPPORTED_OPERATION_ERROR_CODE ;
15+ import static io .a2a .spec .DataPart .DATA ;
16+ import static io .a2a .spec .FilePart .FILE ;
17+ import static io .a2a .spec .TextPart .TEXT ;
18+ import static java .lang .String .format ;
1519
1620import java .io .StringReader ;
1721import java .lang .reflect .Type ;
1822import java .time .OffsetDateTime ;
1923import java .time .format .DateTimeFormatter ;
2024import java .time .format .DateTimeParseException ;
2125import java .util .Map ;
26+ import java .util .Set ;
2227
2328import com .google .gson .Gson ;
2429import com .google .gson .GsonBuilder ;
2934import com .google .gson .stream .JsonReader ;
3035import com .google .gson .stream .JsonToken ;
3136import com .google .gson .stream .JsonWriter ;
37+
3238import io .a2a .spec .A2AError ;
39+ import io .a2a .spec .APIKeySecurityScheme ;
3340import io .a2a .spec .ContentTypeNotSupportedError ;
3441import io .a2a .spec .DataPart ;
3542import io .a2a .spec .FileContent ;
3643import io .a2a .spec .FilePart ;
3744import io .a2a .spec .FileWithBytes ;
3845import io .a2a .spec .FileWithUri ;
46+ import io .a2a .spec .HTTPAuthSecurityScheme ;
3947import io .a2a .spec .InvalidAgentResponseError ;
4048import io .a2a .spec .InvalidParamsError ;
4149import io .a2a .spec .InvalidRequestError ;
4250import io .a2a .spec .JSONParseError ;
4351import io .a2a .spec .Message ;
4452import io .a2a .spec .MethodNotFoundError ;
53+ import io .a2a .spec .MutualTLSSecurityScheme ;
54+ import io .a2a .spec .OAuth2SecurityScheme ;
55+ import io .a2a .spec .OpenIdConnectSecurityScheme ;
4556import io .a2a .spec .Part ;
4657import io .a2a .spec .PushNotificationNotSupportedError ;
58+ import io .a2a .spec .SecurityScheme ;
4759import io .a2a .spec .StreamingEventKind ;
4860import io .a2a .spec .Task ;
4961import io .a2a .spec .TaskArtifactUpdateEvent ;
5365import io .a2a .spec .TaskStatusUpdateEvent ;
5466import io .a2a .spec .TextPart ;
5567import io .a2a .spec .UnsupportedOperationError ;
68+
5669import org .jspecify .annotations .Nullable ;
5770
5871/**
@@ -83,6 +96,7 @@ private static GsonBuilder createBaseGsonBuilder() {
8396 public static final Gson OBJECT_MAPPER = createBaseGsonBuilder ()
8497 .registerTypeHierarchyAdapter (Part .class , new PartTypeAdapter ())
8598 .registerTypeHierarchyAdapter (StreamingEventKind .class , new StreamingEventKindTypeAdapter ())
99+ .registerTypeHierarchyAdapter (SecurityScheme .class , new SecuritySchemeTypeAdapter ())
86100 .create ();
87101
88102 /**
@@ -530,6 +544,8 @@ public void write(JsonWriter out, Message.Role value) throws java.io.IOException
530544 */
531545 static class PartTypeAdapter extends TypeAdapter <Part <?>> {
532546
547+ private static final Set <String > VALID_KEYS = Set .of (TEXT , FILE , DATA );
548+
533549 // Create separate Gson instance without the Part adapter to avoid recursion
534550 private final Gson delegateGson = createBaseGsonBuilder ().create ();
535551
@@ -539,21 +555,20 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
539555 out .nullValue ();
540556 return ;
541557 }
542-
543558 // Write wrapper object with member name as discriminator
544559 out .beginObject ();
545560
546561 if (value instanceof TextPart textPart ) {
547562 // TextPart: { "text": "value" } - direct string value
548- out .name ("text" );
563+ out .name (TEXT );
549564 out .value (textPart .text ());
550565 } else if (value instanceof FilePart filePart ) {
551566 // FilePart: { "file": {...} }
552- out .name ("file" );
567+ out .name (FILE );
553568 delegateGson .toJson (filePart .file (), FileContent .class , out );
554569 } else if (value instanceof DataPart dataPart ) {
555570 // DataPart: { "data": {...} }
556- out .name ("data" );
571+ out .name (DATA );
557572 delegateGson .toJson (dataPart .data (), Map .class , out );
558573 } else {
559574 throw new JsonSyntaxException ("Unknown Part subclass: " + value .getClass ().getName ());
@@ -579,23 +594,27 @@ Part<?> read(JsonReader in) throws java.io.IOException {
579594 com .google .gson .JsonObject jsonObject = jsonElement .getAsJsonObject ();
580595
581596 // Check for member name discriminators (v1.0 protocol)
582- if (jsonObject .has ("text" )) {
583- // TextPart: { "text": "value" } - direct string value
584- return new TextPart (jsonObject .get ("text" ).getAsString ());
585- } else if (jsonObject .has ("file" )) {
586- // FilePart: { "file": {...} }
587- return new FilePart (delegateGson .fromJson (jsonObject .get ("file" ), FileContent .class ));
588- } else if (jsonObject .has ("data" )) {
589- // DataPart: { "data": {...} }
590- @ SuppressWarnings ("unchecked" )
591- Map <String , Object > dataMap = delegateGson .fromJson (
592- jsonObject .get ("data" ),
593- new TypeToken <Map <String , Object >>(){}.getType ()
594- );
595- return new DataPart (dataMap );
596- } else {
597- throw new JsonSyntaxException ("Part must have one of: text, file, data (found: " + jsonObject .keySet () + ")" );
598- }
597+ Set <String > keys = jsonObject .keySet ();
598+ if (keys .size () != 1 ) {
599+ throw new JsonSyntaxException (format ("Part object must have exactly one key, which must be one of: %s (found: %s)" , VALID_KEYS , keys ));
600+ }
601+
602+ String discriminator = keys .iterator ().next ();
603+
604+ return switch (discriminator ) {
605+ case TEXT -> new TextPart (jsonObject .get (TEXT ).getAsString ());
606+ case FILE -> new FilePart (delegateGson .fromJson (jsonObject .get (FILE ), FileContent .class ));
607+ case DATA -> {
608+ @ SuppressWarnings ("unchecked" )
609+ Map <String , Object > dataMap = delegateGson .fromJson (
610+ jsonObject .get (DATA ),
611+ new TypeToken <Map <String , Object >>(){}.getType ()
612+ );
613+ yield new DataPart (dataMap );
614+ }
615+ default ->
616+ throw new JsonSyntaxException (format ("Part must have one of: %s (found: %s)" , VALID_KEYS , discriminator ));
617+ };
599618 }
600619 }
601620
@@ -627,20 +646,10 @@ public void write(JsonWriter out, StreamingEventKind value) throws java.io.IOExc
627646 out .nullValue ();
628647 return ;
629648 }
630-
631649 // Write wrapper object with member name as discriminator
632650 out .beginObject ();
633-
634- Type type = switch (value .kind ()) {
635- case Task .STREAMING_EVENT_ID -> Task .class ;
636- case Message .STREAMING_EVENT_ID -> Message .class ;
637- case TaskStatusUpdateEvent .STREAMING_EVENT_ID -> TaskStatusUpdateEvent .class ;
638- case TaskArtifactUpdateEvent .STREAMING_EVENT_ID -> TaskArtifactUpdateEvent .class ;
639- default -> throw new JsonSyntaxException ("Unknown StreamingEventKind implementation: " + value .getClass ().getName ());
640- };
641-
642651 out .name (value .kind ());
643- delegateGson .toJson (value , type , out );
652+ delegateGson .toJson (value , value . getClass () , out );
644653 out .endObject ();
645654 }
646655
@@ -714,7 +723,9 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException {
714723 static class FileContentTypeAdapter extends TypeAdapter <FileContent > {
715724
716725 // Create separate Gson instance without the FileContent adapter to avoid recursion
717- private final Gson delegateGson = new Gson ();
726+ private final Gson delegateGson = new GsonBuilder ()
727+ .registerTypeAdapter (OffsetDateTime .class , new OffsetDateTimeTypeAdapter ())
728+ .create ();
718729
719730 @ Override
720731 public void write (JsonWriter out , FileContent value ) throws java .io .IOException {
@@ -723,13 +734,7 @@ public void write(JsonWriter out, FileContent value) throws java.io.IOException
723734 return ;
724735 }
725736 // Delegate to Gson's default serialization for the concrete type
726- if (value instanceof FileWithBytes fileWithBytes ) {
727- delegateGson .toJson (fileWithBytes , FileWithBytes .class , out );
728- } else if (value instanceof FileWithUri fileWithUri ) {
729- delegateGson .toJson (fileWithUri , FileWithUri .class , out );
730- } else {
731- throw new JsonSyntaxException ("Unknown FileContent implementation: " + value .getClass ().getName ());
732- }
737+ delegateGson .toJson (value , value .getClass (), out );
733738 }
734739
735740 @ Override
@@ -759,4 +764,151 @@ FileContent read(JsonReader in) throws java.io.IOException {
759764 }
760765 }
761766
767+ /**
768+ * Gson TypeAdapter for serializing and deserializing {@link APIKeySecurityScheme.Location} enum.
769+ * <p>
770+ * This adapter ensures that Location enum values are serialized using their
771+ * wire format string representation (e.g., "header") rather than
772+ * the Java enum constant name (e.g., "HEADER").
773+ * <p>
774+ * For serialization, it uses {@link APIKeySecurityScheme.Location#asString()} to get the wire format.
775+ * For deserialization, it uses {@link APIKeySecurityScheme.Location#fromString(String)} to parse the
776+ * wire format back to the enum constant.
777+ *
778+ * @see APIKeySecurityScheme.Location
779+ */
780+ static class APIKeyLocationTypeAdapter extends TypeAdapter <APIKeySecurityScheme .Location > {
781+
782+ @ Override
783+ public void write (JsonWriter out , APIKeySecurityScheme .Location value ) throws java .io .IOException {
784+ if (value == null ) {
785+ out .nullValue ();
786+ return ;
787+ }
788+ out .value (value .asString ());
789+ }
790+
791+ @ Override
792+ public APIKeySecurityScheme .@ Nullable Location read (JsonReader in ) throws java .io .IOException {
793+ if (in .peek () == JsonToken .NULL ) {
794+ in .nextNull ();
795+ return null ;
796+ }
797+ String locationString = in .nextString ();
798+ try {
799+ return APIKeySecurityScheme .Location .fromString (locationString );
800+ } catch (IllegalArgumentException e ) {
801+ throw new JsonSyntaxException ("Invalid APIKeySecurityScheme.Location: " + locationString , e );
802+ }
803+ }
804+ }
805+
806+ /**
807+ * Gson TypeAdapter for serializing and deserializing {@link SecurityScheme} and its implementations.
808+ * <p>
809+ * This adapter handles polymorphic deserialization for the sealed SecurityScheme interface,
810+ * which permits five implementations:
811+ * <ul>
812+ * <li>{@link APIKeySecurityScheme} - API key authentication</li>
813+ * <li>{@link HTTPAuthSecurityScheme} - HTTP authentication (basic or bearer)</li>
814+ * <li>{@link OAuth2SecurityScheme} - OAuth 2.0 flows</li>
815+ * <li>{@link OpenIdConnectSecurityScheme} - OpenID Connect discovery</li>
816+ * <li>{@link MutualTLSSecurityScheme} - Client certificate authentication</li>
817+ * </ul>
818+ * <p>
819+ * The adapter uses a wrapper object with the security scheme type as the discriminator field.
820+ * Each SecurityScheme is serialized as a JSON object with a single field whose name identifies
821+ * the security scheme type.
822+ * <p>
823+ * Serialization format examples:
824+ * <pre>{@code
825+ * // HTTPAuthSecurityScheme
826+ * {
827+ * "httpAuthSecurityScheme": {
828+ * "scheme": "bearer",
829+ * "bearerFormat": "JWT",
830+ * "description": "..."
831+ * }
832+ * }
833+ *
834+ * // APIKeySecurityScheme
835+ * {
836+ * "apiKeySecurityScheme": {
837+ * "location": "header",
838+ * "name": "X-API-Key",
839+ * "description": "..."
840+ * }
841+ * }
842+ * }</pre>
843+ *
844+ * @see SecurityScheme
845+ * @see APIKeySecurityScheme
846+ * @see HTTPAuthSecurityScheme
847+ * @see OAuth2SecurityScheme
848+ * @see OpenIdConnectSecurityScheme
849+ * @see MutualTLSSecurityScheme
850+ */
851+ static class SecuritySchemeTypeAdapter extends TypeAdapter <SecurityScheme > {
852+
853+ private static final Set <String > VALID_KEYS = Set .of (APIKeySecurityScheme .TYPE ,
854+ HTTPAuthSecurityScheme .TYPE ,
855+ OAuth2SecurityScheme .TYPE ,
856+ OpenIdConnectSecurityScheme .TYPE ,
857+ MutualTLSSecurityScheme .TYPE );
858+
859+ // Create separate Gson instance without the SecurityScheme adapter to avoid recursion
860+ // Register custom adapter for APIKeySecurityScheme.Location enum
861+ private final Gson delegateGson = createBaseGsonBuilder ()
862+ .registerTypeAdapter (APIKeySecurityScheme .Location .class , new APIKeyLocationTypeAdapter ())
863+ .create ();
864+
865+ @ Override
866+ public void write (JsonWriter out , SecurityScheme value ) throws java .io .IOException {
867+ if (value == null ) {
868+ out .nullValue ();
869+ return ;
870+ }
871+
872+ // Write wrapper object with member name as discriminator
873+ out .beginObject ();
874+ out .name (value .type ());
875+ delegateGson .toJson (value , value .getClass (), out );
876+ out .endObject ();
877+ }
878+
879+ @ Override
880+ public @ Nullable
881+ SecurityScheme read (JsonReader in ) throws java .io .IOException {
882+ if (in .peek () == JsonToken .NULL ) {
883+ in .nextNull ();
884+ return null ;
885+ }
886+
887+ // Read the JSON as a tree to inspect the member name discriminator
888+ com .google .gson .JsonElement jsonElement = com .google .gson .JsonParser .parseReader (in );
889+ if (!jsonElement .isJsonObject ()) {
890+ throw new JsonSyntaxException ("SecurityScheme must be a JSON object" );
891+ }
892+
893+ com .google .gson .JsonObject jsonObject = jsonElement .getAsJsonObject ();
894+
895+ // Check for member name discriminators
896+ Set <String > keys = jsonObject .keySet ();
897+ if (keys .size () != 1 ) {
898+ throw new JsonSyntaxException (format ("A SecurityScheme object must have exactly one key, which must be one of: %s (found: %s)" , VALID_KEYS , keys ));
899+ }
900+
901+ String discriminator = keys .iterator ().next ();
902+ com .google .gson .JsonElement nestedObject = jsonObject .get (discriminator );
903+
904+ return switch (discriminator ) {
905+ case APIKeySecurityScheme .TYPE -> delegateGson .fromJson (nestedObject , APIKeySecurityScheme .class );
906+ case HTTPAuthSecurityScheme .TYPE -> delegateGson .fromJson (nestedObject , HTTPAuthSecurityScheme .class );
907+ case OAuth2SecurityScheme .TYPE -> delegateGson .fromJson (nestedObject , OAuth2SecurityScheme .class );
908+ case OpenIdConnectSecurityScheme .TYPE -> delegateGson .fromJson (nestedObject , OpenIdConnectSecurityScheme .class );
909+ case MutualTLSSecurityScheme .TYPE -> delegateGson .fromJson (nestedObject , MutualTLSSecurityScheme .class );
910+ default -> throw new JsonSyntaxException (format ("Unknown SecurityScheme type. Must be one of: %s (found: %s)" , VALID_KEYS , discriminator ));
911+ };
912+ }
913+ }
762914}
0 commit comments