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.WorkerContextValidationSupportAdapter;
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
078public class GraphQLProviderWithIntrospection extends GraphQLProvider {
079
080        private static final Logger ourLog = LoggerFactory.getLogger(GraphQLProviderWithIntrospection.class);
081        private final Supplier<GraphQLSchemaGenerator> myGenerator;
082        private final ISearchParamRegistry mySearchParamRegistry;
083        private final WorkerContextValidationSupportAdapter myContext;
084        private final IDaoRegistry myDaoRegistry;
085        private final Gson myGson;
086
087        /**
088         * Constructor
089         */
090        public GraphQLProviderWithIntrospection(
091                        FhirContext theFhirContext,
092                        IValidationSupport theValidationSupport,
093                        IGraphQLStorageServices theIGraphQLStorageServices,
094                        ISearchParamRegistry theSearchParamRegistry,
095                        IDaoRegistry theDaoRegistry) {
096                super(theFhirContext, theValidationSupport, theIGraphQLStorageServices);
097
098                mySearchParamRegistry = theSearchParamRegistry;
099                myDaoRegistry = theDaoRegistry;
100
101                myContext = WorkerContextValidationSupportAdapter.newVersionSpecificWorkerContextWrapper(theValidationSupport);
102
103                GsonBuilder gsonBuilder = new GsonBuilder();
104                gsonBuilder.registerTypeAdapter(Collections.emptyList().getClass(), (JsonSerializer<Object>)
105                                (src, typeOfSrc, context) -> new JsonArray());
106                myGson = gsonBuilder.create();
107
108                // Lazy-load this because it's expensive, but more importantly because it makes a bunch of
109                // calls for StructureDefinitions and other such things during startup so we want to be sure
110                // that everything else is initialized first
111                myGenerator = Suppliers.memoize(() -> new GraphQLSchemaGenerator(myContext, VersionUtil.getVersion()));
112        }
113
114        @Override
115        public String processGraphQlGetRequest(ServletRequestDetails theRequestDetails, IIdType theId, String theQueryUrl) {
116                return super.processGraphQlGetRequest(theRequestDetails, theId, theQueryUrl);
117        }
118
119        @Override
120        public String processGraphQlPostRequest(
121                        ServletRequestDetails theServletRequestDetails,
122                        RequestDetails theRequestDetails,
123                        IIdType theId,
124                        String theQueryBody) {
125                if (theQueryBody.contains("__schema")) {
126                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> operations;
127                        if (theId != null) {
128                                throw new InvalidRequestException(
129                                                Msg.code(2035)
130                                                                + "GraphQL introspection not supported at instance level. Please try at server- or instance- level.");
131                        }
132
133                        operations = EnumSet.of(
134                                        GraphQLSchemaGenerator.FHIROperationType.READ, GraphQLSchemaGenerator.FHIROperationType.SEARCH);
135
136                        Collection<String> resourceTypes;
137                        if (theRequestDetails.getResourceName() != null) {
138                                resourceTypes = Collections.singleton(theRequestDetails.getResourceName());
139                        } else {
140                                resourceTypes = new HashSet<>();
141                                for (String next : myContext.getResourceNames()) {
142                                        if (myDaoRegistry.isResourceTypeSupported(next)) {
143                                                resourceTypes.add(next);
144                                        }
145                                }
146                                resourceTypes = resourceTypes.stream().sorted().collect(Collectors.toList());
147                        }
148
149                        return generateSchema(theQueryBody, resourceTypes, operations);
150                } else {
151                        return super.processGraphQlPostRequest(theServletRequestDetails, theRequestDetails, theId, theQueryBody);
152                }
153        }
154
155        private String generateSchema(
156                        String theQueryBody,
157                        Collection<String> theResourceTypes,
158                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> theOperations) {
159
160                GraphQLSchemaGenerator generator = myGenerator.get();
161
162                final StringBuilder schemaBuilder = new StringBuilder();
163                try (Writer writer = new StringBuilderWriter(schemaBuilder)) {
164
165                        // Generate FHIR base types schemas
166                        generator.generateTypes(writer, theOperations);
167
168                        // Fix up a few things that are missing from the generated schema
169                        writer.append("\ninterface Element {")
170                                        .append("\n  id: ID")
171                                        .append("\n}")
172                                        .append("\n");
173
174                        // Generate schemas for the resource types
175                        for (String nextResourceType : theResourceTypes) {
176                                StructureDefinition sd = fetchStructureDefinition(nextResourceType);
177                                List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
178                                                .getActiveSearchParams(
179                                                                nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
180                                                .values());
181                                generator.generateResource(writer, sd, parameters, theOperations);
182                        }
183
184                        // Generate queries
185                        writer.append("\ntype Query {");
186                        for (String nextResourceType : theResourceTypes) {
187                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.READ)) {
188                                        writer.append("\n  ")
189                                                        .append(nextResourceType)
190                                                        .append("(id: String): ")
191                                                        .append(nextResourceType)
192                                                        .append("\n");
193                                }
194                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.SEARCH)) {
195                                        List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
196                                                        .getActiveSearchParams(
197                                                                        nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
198                                                        .values());
199                                        generator.generateListAccessQuery(writer, parameters, nextResourceType);
200                                        generator.generateConnectionAccessQuery(writer, parameters, nextResourceType);
201                                }
202                        }
203                        writer.append("\n}");
204
205                        writer.flush();
206                } catch (IOException e) {
207                        throw new InternalErrorException(Msg.code(2036) + e.getMessage(), e);
208                }
209
210                String schema = schemaBuilder
211                                .toString()
212                                // Use UNIX line endings in the output
213                                .replace("\r", "")
214                                // Make sure we consistently use the same datatype for all IDs - The
215                                // corelib GraphQL generator uses "ID" sometimes and "String" in others
216                                // and the GraphQL tooling doesn't like that
217                                .replace(" id: ID\n", " id: String\n");
218
219                // Set these to INFO if you're testing, then set back before committing
220                ourLog.debug("Schema generated: {} chars", schema.length());
221                ourLog.atDebug()
222                                .setMessage("Schema generated:\n{}")
223                                .addArgument(() -> StringUtil.prependLineNumbers(schema))
224                                .log();
225
226                SchemaParser schemaParser = new SchemaParser();
227                TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema);
228
229                SchemaGenerator schemaGenerator = new SchemaGenerator();
230                RuntimeWiring.Builder runtimeWiringBuilder = RuntimeWiring.newRuntimeWiring();
231                for (String next : typeDefinitionRegistry.scalars().keySet()) {
232                        if (Character.isUpperCase(next.charAt(0))) {
233                                // Skip GraphQL built-in types
234                                continue;
235                        }
236                        runtimeWiringBuilder.scalar(new GraphQLScalarType.Builder()
237                                        .name(next)
238                                        .coercing(new GraphqlStringCoercing())
239                                        .build());
240                }
241
242                for (InterfaceTypeDefinition next : typeDefinitionRegistry.getTypes(InterfaceTypeDefinition.class)) {
243                        TypeResolver resolver = new TypeResolverProxy();
244                        TypeRuntimeWiring wiring = TypeRuntimeWiring.newTypeWiring(next.getName())
245                                        .typeResolver(resolver)
246                                        .build();
247                        runtimeWiringBuilder.type(wiring);
248                }
249
250                RuntimeWiring runtimeWiring = runtimeWiringBuilder.build();
251                GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
252
253                GraphQL build = GraphQL.newGraphQL(graphQLSchema).build();
254                ExecutionResult executionResult = build.execute(theQueryBody);
255
256                Map<String, Object> data = executionResult.toSpecification();
257                return myGson.toJson(data);
258        }
259
260        @Nonnull
261        private List<SearchParameter> toR5SearchParams(Collection<RuntimeSearchParam> searchParams) {
262                List<SearchParameter> parameters = new ArrayList<>();
263                for (RuntimeSearchParam next : searchParams) {
264                        SearchParameter sp = toR5SearchParam(next);
265                        if (sp != null) {
266                                parameters.add(sp);
267                        }
268                }
269                return parameters;
270        }
271
272        @Nullable
273        private SearchParameter toR5SearchParam(RuntimeSearchParam next) {
274                SearchParameter sp = new SearchParameter();
275                sp.setUrl(next.getUri());
276                sp.setCode(next.getName());
277                sp.setName(next.getName());
278
279                switch (next.getParamType()) {
280                        case NUMBER:
281                                sp.setType(Enumerations.SearchParamType.NUMBER);
282                                break;
283                        case DATE:
284                                sp.setType(Enumerations.SearchParamType.DATE);
285                                break;
286                        case STRING:
287                                sp.setType(Enumerations.SearchParamType.STRING);
288                                break;
289                        case TOKEN:
290                                sp.setType(Enumerations.SearchParamType.TOKEN);
291                                break;
292                        case REFERENCE:
293                                sp.setType(Enumerations.SearchParamType.REFERENCE);
294                                break;
295                        case COMPOSITE:
296                                sp.setType(Enumerations.SearchParamType.COMPOSITE);
297                                break;
298                        case QUANTITY:
299                                sp.setType(Enumerations.SearchParamType.QUANTITY);
300                                break;
301                        case URI:
302                                sp.setType(Enumerations.SearchParamType.URI);
303                                break;
304                        case HAS:
305                        case SPECIAL:
306                        default:
307                                return null;
308                }
309                return sp;
310        }
311
312        @Nonnull
313        private StructureDefinition fetchStructureDefinition(String resourceName) {
314                StructureDefinition retVal = myContext.fetchResource(
315                                StructureDefinition.class, "http://hl7.org/fhir/StructureDefinition/" + resourceName);
316                Validate.notNull(retVal);
317                return retVal;
318        }
319}