diff --git a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/QueryBuilder.java b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/QueryBuilder.java index 24e7bffa8..cf30c3c9e 100644 --- a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/QueryBuilder.java +++ b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/QueryBuilder.java @@ -27,6 +27,12 @@ public String build() { if (method.hasValueParameters()) request.append(method.valueParameters().map(this::declare).collect(joining(", ", "(", ")"))); + String groupName = method.getGroupName(); + if (groupName != null) { + request.append(" { "); + request.append(groupName); + } + if (method.isSingle()) { request.append(" { "); request.append(method.getName()); @@ -41,6 +47,9 @@ public String build() { if (method.isSingle()) request.append(" }"); + if (groupName != null) + request.append(" } "); + return request.toString(); } diff --git a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/ResultBuilder.java b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/ResultBuilder.java index 607b4cd7c..30e02bd4f 100644 --- a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/ResultBuilder.java +++ b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/ResultBuilder.java @@ -92,7 +92,10 @@ public Object read() { private JsonObject readData() { if (!response.containsKey("data") || response.isNull("data")) return null; - JsonObject data = response.getJsonObject("data"); + String groupName = method.getGroupName(); + JsonObject data = groupName != null + ? response.getJsonObject("data").getJsonObject(groupName) + : response.getJsonObject("data"); if (method.isSingle() && !data.containsKey(method.getName())) throw new InvalidResponseException("No data for '" + method.getName() + "'"); return data; diff --git a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/reflection/MethodInvocation.java b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/reflection/MethodInvocation.java index b4e2f8485..1a8a49504 100644 --- a/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/reflection/MethodInvocation.java +++ b/client/implementation/src/main/java/io/smallrye/graphql/client/impl/typesafe/reflection/MethodInvocation.java @@ -36,12 +36,14 @@ public static MethodInvocation of(Method method, Object... args) { private final TypeInfo type; private final Method method; private final Object[] parameterValues; + private final String groupName; private List parameters; private MethodInvocation(TypeInfo type, Method method, Object[] parameterValues) { this.type = type; this.method = method; this.parameterValues = parameterValues; + this.groupName = readGroupName(method); } @Override @@ -259,4 +261,19 @@ public String getOperationTypeAsString() { return "query"; } } + + public String getGroupName() { + return groupName; + } + + private String readGroupName(Method method) { + Name annotation = method.getDeclaringClass().getAnnotation(Name.class); + if (annotation != null) { + String groupName = annotation.value().trim(); + if (!groupName.isEmpty()) { + return groupName; + } + } + return null; + } } diff --git a/client/model-builder/src/main/java/io/smallrye/graphql/client/model/QueryBuilder.java b/client/model-builder/src/main/java/io/smallrye/graphql/client/model/QueryBuilder.java index 791e27dc6..4470c877b 100644 --- a/client/model-builder/src/main/java/io/smallrye/graphql/client/model/QueryBuilder.java +++ b/client/model-builder/src/main/java/io/smallrye/graphql/client/model/QueryBuilder.java @@ -39,6 +39,12 @@ public String build() { request.append(method.valueParameters().stream().map(method::declare).collect(joining(", ", "(", ")"))); } + String groupName = method.getGroupName(); + if (groupName != null) { + request.append(" { "); + request.append(groupName); + } + if (method.isSingle()) { request.append(" { "); request.append(method.getName()); @@ -58,6 +64,9 @@ public String build() { if (method.isSingle()) request.append(" }"); + if (groupName != null) + request.append(" } "); + return request.toString(); } } diff --git a/client/model-builder/src/main/java/io/smallrye/graphql/client/model/helper/OperationModel.java b/client/model-builder/src/main/java/io/smallrye/graphql/client/model/helper/OperationModel.java index 4add9f429..cbfc70218 100644 --- a/client/model-builder/src/main/java/io/smallrye/graphql/client/model/helper/OperationModel.java +++ b/client/model-builder/src/main/java/io/smallrye/graphql/client/model/helper/OperationModel.java @@ -36,6 +36,7 @@ public class OperationModel implements NamedElement { private final Stack expressionStack = new Stack<>(); private Stack rawParametrizedTypes = new Stack<>(); private final List directives; + private final String groupName; /** * Creates a new {@code OperationModel} instance based on the provided Jandex {@link MethodInfo}. @@ -49,6 +50,7 @@ public class OperationModel implements NamedElement { getDirectiveLocation(), AnnotationTarget.Kind.METHOD) .map(DirectiveInstance::of) .collect(toList()); + this.groupName = readGroupName(method); } /** @@ -388,4 +390,23 @@ private Optional getMethodAnnotation(DotName annotation) { private boolean isRawParametrizedType(TypeModel type) { return type.isCustomParametrizedType() && !type.getFirstRawType().isTypeVariable(); } + + public String getGroupName() { + return groupName; + } + + private String readGroupName(MethodInfo method) { + List annotationInstances = method.declaringClass().annotations(NAME); + for (AnnotationInstance annotationInstance : annotationInstances) { + if (annotationInstance.target().kind() == AnnotationTarget.Kind.CLASS) { + if (annotationInstance.target().asClass().name().equals(method.declaringClass().name())) { + String groupName = annotationInstance.value().asString().trim(); + if (!groupName.isEmpty()) { + return groupName; + } + } + } + } + return null; + } } diff --git a/client/model-builder/src/test/java/io/smallrye/graphql/client/model/ClientModelBuilderTest.java b/client/model-builder/src/test/java/io/smallrye/graphql/client/model/ClientModelBuilderTest.java index 5b04a8a3d..a03e7a415 100644 --- a/client/model-builder/src/test/java/io/smallrye/graphql/client/model/ClientModelBuilderTest.java +++ b/client/model-builder/src/test/java/io/smallrye/graphql/client/model/ClientModelBuilderTest.java @@ -11,6 +11,7 @@ import java.util.Set; import org.eclipse.microprofile.graphql.Mutation; +import org.eclipse.microprofile.graphql.Name; import org.eclipse.microprofile.graphql.Query; import org.jboss.jandex.Index; import org.junit.jupiter.api.Test; @@ -210,6 +211,39 @@ void inheritedOperationsClientModelTest() throws IOException { "query allStrings0 { allStrings0 }"); } + @Name("named") + @GraphQLClientApi(configKey = "string-api") + interface NamedClientApi { + @Query("findAll") + List findAllStringsQuery(); + + @Name("findAll") + List findAllStringsName(); + + @Mutation("update") + @Name("update") + String update(String s); + } + + @Test + void namedClientModelTest() throws IOException { + String configKey = "string-api"; + ClientModels clientModels = ClientModelBuilder + .build(Index.of(NamedClientApi.class)); + assertNotNull(clientModels.getClientModelByConfigKey(configKey)); + ClientModel clientModel = clientModels.getClientModelByConfigKey(configKey); + assertEquals(3, clientModel.getOperationMap().size()); + assertOperation(clientModel, + new MethodKey("findAllStringsQuery", new Class[0]), + "query findAll { named { findAll } } "); + assertOperation(clientModel, + new MethodKey("findAllStringsName", new Class[0]), + "query findAll { named { findAll } } "); + assertOperation(clientModel, + new MethodKey("update", new Class[] { String.class }), + "mutation update($s: String) { named { update(s: $s) } } "); + } + private void assertOperation(ClientModel clientModel, MethodKey methodKey, String expectedQuery) { String actualQuery = clientModel.getOperationMap().get(methodKey); assertEquals(expectedQuery, actualQuery); diff --git a/docs/namespaces-on-server-side.md b/docs/namespaces-on-server-side.md new file mode 100644 index 000000000..0fbf49dd2 --- /dev/null +++ b/docs/namespaces-on-server-side.md @@ -0,0 +1,197 @@ +# Namespacing on the server side + +## Before you continue reading +> [NOTE] +> Using approaches to form namespaces in the schema can be useful for large APIs. There are several ways to do this. +> However, read the documentation carefully, especially the limitations and possible problems. + +## How use namespaces +There are 3 options how to use the name space - use the @Name annotation, @Source, or combine them. + +### Using @Name annotation +The easiest way is that you can separate your API into namespace areas using the annotation @Name with @GraphQLApi. +```java +@GraphQLApi +@Name("users") +@Description("Users operations") +public class UserApi { + @Query + public List findAll() { + // + } +} + +@GraphQLApi +@Name("roles") +@Description("Roles operations") +public class RoleApi { + @Query + public List findAll() { + // + } +} +``` +As a result, you can get methods with the same names. +``` +query { + users { + findAll { + .... + } + } + roles { + findAll { + .... + } + } +} +``` +When using annotation @Name, will be generated type - NameQuery, NameMutation and NameSubscription +(Subscriptions placed in this type will not work. More details below). + +### Using @Source annotation for deep nesting +You can use the @Source annotation to create deep nesting of namespaces. +```java +// create classes that represent namespaces +public class AdminQueryNamespace { +} + +public class AdminMutationNamespace { +} + +public class UserQueryNamespace { +} + +public class UserMutationNamespace { +} + +@GraphQLApi +public class UserApi { + @Query("admin") + public AdminQueryNamespace adminQueryNamespace() { + return new AdminQueryNamespace(); + } + + public UserQueryNamespace userQueryNamespace(@Source AdminQueryNamespace namespace) { + return new UserQueryNamespace(); + } + + public List findAll(@Source UserQueryNamespace namespace) { + // return users; + } + + @Mutation("admin") + public AdminMutationNamespace adminMutationNamespace() { + return new AdminMutationNamespace(); + } + + public UserMutationNamespace userMutationNamespace(@Source AdminMutationNamespace namespace) { + return new UserMutationNamespace(); + } + + public List save(@Source UserMutationNamespace namespace, User user) { + // save user + } +} +``` +As a result, you will be able to execute the following query. +``` +query { + admin { + users { + findAll { + .... + } + } + } +} + +mutation { + admin { + users { + save (user: ...) { + .... + } + } + } +} +``` +### Using @Source and @Name annotations together for deep nesting + +You can also simplify this example by using @Name. +```java +// create classes that represent namespaces +public class UserQueryNamespace { +} + +public class UserMutationNamespace { +} + +@GraphQLApi +@Name("admin") +@Description("Users operations") +public class UserApi { + @Query("users") + public UserQueryNamespace userQueryNamespace() { + return new UserQueryNamespace(); + } + + public List findAll(@Source UserQueryNamespace namespace) { + // return users; + } + + @Mutation("users") + public UserMutationNamespace userMutationNamespace() { + return new UserMutationNamespace(); + } + + public List save(@Source UserMutationNamespace namespace, User user) { + // save user + } +} +``` +## Problems +While dividing APIs into namespaces may seem convenient, it has several issues that are important to be aware of. + +#### Mutations +Be careful when working with mutations on client. +This violates the Graphql specification, since mutations in this form can be executed in parallel. +Read more here about namespaces [Namespacing by Separation of Concerns](https://www.apollographql.com/docs/technotes/TN0012-namespacing-by-separation-of-concern/). +This article describes how you can work with namespaces, what problems you may encounter, and how to solve them. + +What does Graphql say about this - ["GraphQL" Nested Mutations](https://benjie.dev/graphql/nested-mutations) + +In summary, you can use nested mutations, but with some overhead on client. Be careful with mutations. + +#### Subscriptions +Graphql does not allow creating subscriptions inside namespaces. +Or rather, you can create them, generated schema will be valid, but the subscription will not be resolved. +As example, if you try to run such a subscription request, you will get an error. This is the behavior of `graphql-java`. + +```java +@GraphQLApi +@Name("resource") +public class ResourceApi { + @Subscription + public Multi resourceChange() { + // + } +} +``` + +``` +subscription { + resource { + resourceChange { + .... + } + } +} +``` + +There is currently no way around this problem. +You must move subscriptions into a separate class that is not placed in a namespace. + +> [NOTE] +> Be very careful when designing API with namespace. +> And take into account all the features of working with mutations and subscriptions. diff --git a/docs/typesafe-client-usage.md b/docs/typesafe-client-usage.md index 825d114f9..1d9b38b71 100644 --- a/docs/typesafe-client-usage.md +++ b/docs/typesafe-client-usage.md @@ -145,3 +145,185 @@ interface TeamsApi { The value of the `@NestedParameter` annotation is the dot-delimited path to the nested field/method that the value should be added to. +Example of server code +```java +@GraphQLApi +public class RoleApi { + @Query + public List findAllRolesByUserId(@NonNull UUID userId) { + // return roles + } + + public List permission(@Source Roles role, @DefaultValue("5") int limit) { + // return permissions, based on roles + } + + public List permissionType(@Source Permission permission, @DefaultValue("5") int limit) { + // return permissionType, based on permission + } +} +``` + +Query looks like +``` +query { + findAllRolesByUserId(userId: ...) { + id + permission(limit: 2) { + id + permissionType(limit: 3) { + id + } + } + } +} +``` + +On client side you can create next code +```java +public record PermissionType(Long id) { +} + +public record Permission(Long id, List permissionType) { +} + +public record Role(UUID id, List permission) { +} + +@GraphQLClientApi +public interface ApiClient { + List findAllRolesByUserId( + UUID userId, + @NestedParameter("permission") @Name("limit") int permissionLimit, + @NestedParameter("permission.permissionType") @Name("limit") int permissionTypeLimit + ); +} +``` + +Namespaces +========== +> [NOTE] +> We strongly unrecommended the use namespaces with a type-safe client. +> It is possible to use the @Name annotation, with minimal changes to the code. +> However, for more complex cases it is better to use the dynamic client, since understanding the resulting code can be difficult. + +There are several ways to work with namespaces with the type-safe client. + +If only 1 level of nesting is used, then the interface can be marked with the @Name annotation. + +``` +query { + users { + findAll { + .... + } + } +} +``` + +```java +@Name("users") +@GraphQLClientApi +public interface ApiClient { + List findAll(); +} +``` + +You also can use Wrapper class (but the code doesn't look great.). + +Modify server code from above. +```java +@Name("roles") // Add namespace +@GraphQLApi +public class RoleApi { + //// +} +``` + +Client code +```java +// Name field roles as method named +public record RolesWrapper(List findAllRolesByUserId) { +} + +@GraphQLClientApi +public interface ApiClient { + @Query("roles") // required naming as namespace + RolesWrapper getRoles( // extend nested params + @NestedParameter("findAllRolesByUserId") UUID userId, // here roles id name of field in wrapper class + @NestedParameter("findAllRolesByUserId.permission") @Name("limit") int permissionLimit, + @NestedParameter("findAllRolesByUserId.permission.permissionType") @Name("limit") int permissionTypeLimit + ); +} +``` + +If you have more than 1 level of nesting and want to use a type-safe client, the only way is with wrapper classes. +```java +@Name("admin") +@GraphQLApi +public class RoleApi { + public static class RoleQueryNamespace{ } + + @Query("roles") + public RoleQueryNamespace roleQueryNamespace(){ + return new RoleQueryNamespace(); + } + + public List findAll(@Source RoleQueryNamespace namespace, UUID userId) {} + public List permission(@Source Role role, @DefaultValue("5") int limit) {} + public List permissionType(@Source Permission permissions, @DefaultValue("5") int limit) {} +} +``` + +Query will be like +``` +query { + admin { + roles { + findAll(userId: ...) { + id + permission(limit: 1) { + id + permissionType(limit: 2) { + id + } + } + } + } + } +} +``` + +```java +// Without Name annotation it must have name as is in schema +public record AdminWrapper(RolesWrapper roles) { // namespace in schema + public record RolesWrapper(List findAllRolesByUserId){} // name method in schema +} + +@GraphQLClientApi(endpoint = "http://localhost:8081/graphql") +public interface ApiClient { + @Query("admin") + AdminWrapper findAllRolesByUserId( + // nested params must be such as field names in wrapper class + @NestedParameter("roles.findAllRolesByUserId") UUID userId, + @NestedParameter("roles.findAllRolesByUserId.permission") @Name("limit") int permissionLimit, + @NestedParameter("roles.findAllRolesByUserId.permission.permissionType") @Name("limit") int permissionTypeLimit + ); +} +/// or +public record AdminWrapper(@Name("roles") RolesWrapper rolesWrapper) { // @Name like namespace in schema + public record RolesWrapper(@Name("findAllRolesByUserId") List roles){} // @Name like method name in schema +} + +@GraphQLClientApi +public interface ApiClient { + @Query("admin") + AdminWrapper findAllRolesByUserId( + // nested params value must be such as field names in wrapper class + @NestedParameter("rolesWrapper.roles") UUID userId, + @NestedParameter("rolesWrapper.roles.permission") @Name("limit") int permissionLimit, + @NestedParameter("rolesWrapper.roles.permission.permissionType") @Name("limit") int permissionTypeLimit + ); +} +``` +As you can see, the code with wrapper classes is quite messy, so we don't recommend using this approach in a type-safe client. diff --git a/mkdocs.yml b/mkdocs.yml index 8f27a0029..68dffa828 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - Handling of the WebSocket's init-payload: 'handling-init-payload-from-the-websocket.md' - Custom scalars: 'custom-scalar.md' - Inspecting executable directives: 'inspecting-executable-directives-on-server-side.md' + - Namespaces: 'namespaces-on-server-side.md' - Typesafe client: - Basic usage: 'typesafe-client-usage.md' - Reactive: 'typesafe-client-reactive-types.md'