001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.graphql;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeSearchParam;
024import ca.uhn.fhir.context.support.IValidationSupport;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.jpa.api.IDaoRegistry;
027import ca.uhn.fhir.rest.api.server.RequestDetails;
028import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
029import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
030import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
031import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
032import ca.uhn.fhir.util.StringUtil;
033import ca.uhn.fhir.util.VersionUtil;
034import com.google.common.base.Supplier;
035import com.google.common.base.Suppliers;
036import com.google.gson.Gson;
037import com.google.gson.GsonBuilder;
038import com.google.gson.JsonArray;
039import com.google.gson.JsonSerializer;
040import graphql.ExecutionResult;
041import graphql.GraphQL;
042import graphql.language.InterfaceTypeDefinition;
043import graphql.scalar.GraphqlStringCoercing;
044import graphql.schema.GraphQLScalarType;
045import graphql.schema.GraphQLSchema;
046import graphql.schema.TypeResolver;
047import graphql.schema.TypeResolverProxy;
048import graphql.schema.idl.RuntimeWiring;
049import graphql.schema.idl.SchemaGenerator;
050import graphql.schema.idl.SchemaParser;
051import graphql.schema.idl.TypeDefinitionRegistry;
052import graphql.schema.idl.TypeRuntimeWiring;
053import jakarta.annotation.Nonnull;
054import jakarta.annotation.Nullable;
055import org.apache.commons.io.output.StringBuilderWriter;
056import org.apache.commons.lang3.Validate;
057import org.hl7.fhir.common.hapi.validation.validator.VersionSpecificWorkerContextWrapper;
058import org.hl7.fhir.instance.model.api.IIdType;
059import org.hl7.fhir.r5.model.Enumerations;
060import org.hl7.fhir.r5.model.SearchParameter;
061import org.hl7.fhir.r5.model.StructureDefinition;
062import org.hl7.fhir.r5.utils.GraphQLSchemaGenerator;
063import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
064import org.slf4j.Logger;
065import org.slf4j.LoggerFactory;
066
067import java.io.IOException;
068import java.io.Writer;
069import java.util.ArrayList;
070import java.util.Collection;
071import java.util.Collections;
072import java.util.EnumSet;
073import java.util.HashSet;
074import java.util.List;
075import java.util.Map;
076import java.util.stream.Collectors;
077
078import static ca.uhn.fhir.util.MessageSupplier.msg;
079
080public class GraphQLProviderWithIntrospection extends GraphQLProvider {
081
082        private static final Logger ourLog = LoggerFactory.getLogger(GraphQLProviderWithIntrospection.class);
083        private final Supplier<GraphQLSchemaGenerator> myGenerator;
084        private final ISearchParamRegistry mySearchParamRegistry;
085        private final VersionSpecificWorkerContextWrapper myContext;
086        private final IDaoRegistry myDaoRegistry;
087        private final Gson myGson;
088
089        /**
090         * Constructor
091         */
092        public GraphQLProviderWithIntrospection(
093                        FhirContext theFhirContext,
094                        IValidationSupport theValidationSupport,
095                        IGraphQLStorageServices theIGraphQLStorageServices,
096                        ISearchParamRegistry theSearchParamRegistry,
097                        IDaoRegistry theDaoRegistry) {
098                super(theFhirContext, theValidationSupport, theIGraphQLStorageServices);
099
100                mySearchParamRegistry = theSearchParamRegistry;
101                myDaoRegistry = theDaoRegistry;
102
103                myContext = VersionSpecificWorkerContextWrapper.newVersionSpecificWorkerContextWrapper(theValidationSupport);
104
105                GsonBuilder gsonBuilder = new GsonBuilder();
106                gsonBuilder.registerTypeAdapter(Collections.emptyList().getClass(), (JsonSerializer<Object>)
107                                (src, typeOfSrc, context) -> new JsonArray());
108                myGson = gsonBuilder.create();
109
110                // Lazy-load this because it's expensive, but more importantly because it makes a bunch of
111                // calls for StructureDefinitions and other such things during startup so we want to be sure
112                // that everything else is initialized first
113                myGenerator = Suppliers.memoize(() -> new GraphQLSchemaGenerator(myContext, VersionUtil.getVersion()));
114        }
115
116        @Override
117        public String processGraphQlGetRequest(ServletRequestDetails theRequestDetails, IIdType theId, String theQueryUrl) {
118                return super.processGraphQlGetRequest(theRequestDetails, theId, theQueryUrl);
119        }
120
121        @Override
122        public String processGraphQlPostRequest(
123                        ServletRequestDetails theServletRequestDetails,
124                        RequestDetails theRequestDetails,
125                        IIdType theId,
126                        String theQueryBody) {
127                if (theQueryBody.contains("__schema")) {
128                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> operations;
129                        if (theId != null) {
130                                throw new InvalidRequestException(
131                                                Msg.code(2035)
132                                                                + "GraphQL introspection not supported at instance level. Please try at server- or instance- level.");
133                        }
134
135                        operations = EnumSet.of(
136                                        GraphQLSchemaGenerator.FHIROperationType.READ, GraphQLSchemaGenerator.FHIROperationType.SEARCH);
137
138                        Collection<String> resourceTypes;
139                        if (theRequestDetails.getResourceName() != null) {
140                                resourceTypes = Collections.singleton(theRequestDetails.getResourceName());
141                        } else {
142                                resourceTypes = new HashSet<>();
143                                for (String next : myContext.getResourceNames()) {
144                                        if (myDaoRegistry.isResourceTypeSupported(next)) {
145                                                resourceTypes.add(next);
146                                        }
147                                }
148                                resourceTypes = resourceTypes.stream().sorted().collect(Collectors.toList());
149                        }
150
151                        return generateSchema(theQueryBody, resourceTypes, operations);
152                } else {
153                        return super.processGraphQlPostRequest(theServletRequestDetails, theRequestDetails, theId, theQueryBody);
154                }
155        }
156
157        private String generateSchema(
158                        String theQueryBody,
159                        Collection<String> theResourceTypes,
160                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> theOperations) {
161
162                GraphQLSchemaGenerator generator = myGenerator.get();
163
164                final StringBuilder schemaBuilder = new StringBuilder();
165                try (Writer writer = new StringBuilderWriter(schemaBuilder)) {
166
167                        // Generate FHIR base types schemas
168                        generator.generateTypes(writer, theOperations);
169
170                        // Fix up a few things that are missing from the generated schema
171                        writer.append("\ninterface Element {")
172                                        .append("\n  id: ID")
173                                        .append("\n}")
174                                        .append("\n");
175
176                        // Generate schemas for the resource types
177                        for (String nextResourceType : theResourceTypes) {
178                                StructureDefinition sd = fetchStructureDefinition(nextResourceType);
179                                List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
180                                                .getActiveSearchParams(
181                                                                nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
182                                                .values());
183                                generator.generateResource(writer, sd, parameters, theOperations);
184                        }
185
186                        // Generate queries
187                        writer.append("\ntype Query {");
188                        for (String nextResourceType : theResourceTypes) {
189                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.READ)) {
190                                        writer.append("\n  ")
191                                                        .append(nextResourceType)
192                                                        .append("(id: String): ")
193                                                        .append(nextResourceType)
194                                                        .append("\n");
195                                }
196                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.SEARCH)) {
197                                        List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
198                                                        .getActiveSearchParams(
199                                                                        nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
200                                                        .values());
201                                        generator.generateListAccessQuery(writer, parameters, nextResourceType);
202                                        generator.generateConnectionAccessQuery(writer, parameters, nextResourceType);
203                                }
204                        }
205                        writer.append("\n}");
206
207                        writer.flush();
208                } catch (IOException e) {
209                        throw new InternalErrorException(Msg.code(2036) + e.getMessage(), e);
210                }
211
212                String schema = schemaBuilder.toString().replace("\r", "");
213
214                // Set these to INFO if you're testing, then set back before committing
215                ourLog.debug("Schema generated: {} chars", schema.length());
216                ourLog.debug("Schema generated: {}", msg(() -> StringUtil.prependLineNumbers(schema)));
217
218                SchemaParser schemaParser = new SchemaParser();
219                TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema);
220
221                SchemaGenerator schemaGenerator = new SchemaGenerator();
222                RuntimeWiring.Builder runtimeWiringBuilder = RuntimeWiring.newRuntimeWiring();
223                for (String next : typeDefinitionRegistry.scalars().keySet()) {
224                        if (Character.isUpperCase(next.charAt(0))) {
225                                // Skip GraphQL built-in types
226                                continue;
227                        }
228                        runtimeWiringBuilder.scalar(new GraphQLScalarType.Builder()
229                                        .name(next)
230                                        .coercing(new GraphqlStringCoercing())
231                                        .build());
232                }
233
234                for (InterfaceTypeDefinition next : typeDefinitionRegistry.getTypes(InterfaceTypeDefinition.class)) {
235                        TypeResolver resolver = new TypeResolverProxy();
236                        TypeRuntimeWiring wiring = TypeRuntimeWiring.newTypeWiring(next.getName())
237                                        .typeResolver(resolver)
238                                        .build();
239                        runtimeWiringBuilder.type(wiring);
240                }
241
242                RuntimeWiring runtimeWiring = runtimeWiringBuilder.build();
243                GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
244
245                GraphQL build = GraphQL.newGraphQL(graphQLSchema).build();
246                ExecutionResult executionResult = build.execute(theQueryBody);
247
248                Map<String, Object> data = executionResult.toSpecification();
249                return myGson.toJson(data);
250        }
251
252        @Nonnull
253        private List<SearchParameter> toR5SearchParams(Collection<RuntimeSearchParam> searchParams) {
254                List<SearchParameter> parameters = new ArrayList<>();
255                for (RuntimeSearchParam next : searchParams) {
256                        SearchParameter sp = toR5SearchParam(next);
257                        if (sp != null) {
258                                parameters.add(sp);
259                        }
260                }
261                return parameters;
262        }
263
264        @Nullable
265        private SearchParameter toR5SearchParam(RuntimeSearchParam next) {
266                SearchParameter sp = new SearchParameter();
267                sp.setUrl(next.getUri());
268                sp.setCode(next.getName());
269                sp.setName(next.getName());
270
271                switch (next.getParamType()) {
272                        case NUMBER:
273                                sp.setType(Enumerations.SearchParamType.NUMBER);
274                                break;
275                        case DATE:
276                                sp.setType(Enumerations.SearchParamType.DATE);
277                                break;
278                        case STRING:
279                                sp.setType(Enumerations.SearchParamType.STRING);
280                                break;
281                        case TOKEN:
282                                sp.setType(Enumerations.SearchParamType.TOKEN);
283                                break;
284                        case REFERENCE:
285                                sp.setType(Enumerations.SearchParamType.REFERENCE);
286                                break;
287                        case COMPOSITE:
288                                sp.setType(Enumerations.SearchParamType.COMPOSITE);
289                                break;
290                        case QUANTITY:
291                                sp.setType(Enumerations.SearchParamType.QUANTITY);
292                                break;
293                        case URI:
294                                sp.setType(Enumerations.SearchParamType.URI);
295                                break;
296                        case HAS:
297                        case SPECIAL:
298                        default:
299                                return null;
300                }
301                return sp;
302        }
303
304        @Nonnull
305        private StructureDefinition fetchStructureDefinition(String resourceName) {
306                StructureDefinition retVal = myContext.fetchResource(
307                                StructureDefinition.class, "http://hl7.org/fhir/StructureDefinition/" + resourceName);
308                Validate.notNull(retVal);
309                return retVal;
310        }
311}