001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 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.gson.Gson;
035import com.google.gson.GsonBuilder;
036import graphql.ExecutionResult;
037import graphql.GraphQL;
038import graphql.language.InterfaceTypeDefinition;
039import graphql.scalar.GraphqlStringCoercing;
040import graphql.schema.GraphQLScalarType;
041import graphql.schema.GraphQLSchema;
042import graphql.schema.TypeResolver;
043import graphql.schema.TypeResolverProxy;
044import graphql.schema.idl.RuntimeWiring;
045import graphql.schema.idl.SchemaGenerator;
046import graphql.schema.idl.SchemaParser;
047import graphql.schema.idl.TypeDefinitionRegistry;
048import graphql.schema.idl.TypeRuntimeWiring;
049import jakarta.annotation.Nonnull;
050import jakarta.annotation.Nullable;
051import org.apache.commons.io.output.StringBuilderWriter;
052import org.apache.commons.lang3.Validate;
053import org.hl7.fhir.common.hapi.validation.validator.VersionSpecificWorkerContextWrapper;
054import org.hl7.fhir.instance.model.api.IIdType;
055import org.hl7.fhir.r5.model.Enumerations;
056import org.hl7.fhir.r5.model.SearchParameter;
057import org.hl7.fhir.r5.model.StructureDefinition;
058import org.hl7.fhir.r5.utils.GraphQLSchemaGenerator;
059import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
060import org.slf4j.Logger;
061import org.slf4j.LoggerFactory;
062
063import java.io.IOException;
064import java.io.Writer;
065import java.util.ArrayList;
066import java.util.Collection;
067import java.util.Collections;
068import java.util.EnumSet;
069import java.util.HashSet;
070import java.util.List;
071import java.util.Map;
072import java.util.stream.Collectors;
073
074import static ca.uhn.fhir.util.MessageSupplier.msg;
075
076public class GraphQLProviderWithIntrospection extends GraphQLProvider {
077
078        private static final Logger ourLog = LoggerFactory.getLogger(GraphQLProviderWithIntrospection.class);
079        private final GraphQLSchemaGenerator myGenerator;
080        private final ISearchParamRegistry mySearchParamRegistry;
081        private final VersionSpecificWorkerContextWrapper myContext;
082        private final IDaoRegistry myDaoRegistry;
083
084        /**
085         * Constructor
086         */
087        public GraphQLProviderWithIntrospection(
088                        FhirContext theFhirContext,
089                        IValidationSupport theValidationSupport,
090                        IGraphQLStorageServices theIGraphQLStorageServices,
091                        ISearchParamRegistry theSearchParamRegistry,
092                        IDaoRegistry theDaoRegistry) {
093                super(theFhirContext, theValidationSupport, theIGraphQLStorageServices);
094
095                mySearchParamRegistry = theSearchParamRegistry;
096                myDaoRegistry = theDaoRegistry;
097
098                myContext = VersionSpecificWorkerContextWrapper.newVersionSpecificWorkerContextWrapper(theValidationSupport);
099                myGenerator = new GraphQLSchemaGenerator(myContext, VersionUtil.getVersion());
100        }
101
102        @Override
103        public String processGraphQlGetRequest(ServletRequestDetails theRequestDetails, IIdType theId, String theQueryUrl) {
104                return super.processGraphQlGetRequest(theRequestDetails, theId, theQueryUrl);
105        }
106
107        @Override
108        public String processGraphQlPostRequest(
109                        ServletRequestDetails theServletRequestDetails,
110                        RequestDetails theRequestDetails,
111                        IIdType theId,
112                        String theQueryBody) {
113                if (theQueryBody.contains("__schema")) {
114                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> operations;
115                        if (theId != null) {
116                                throw new InvalidRequestException(
117                                                Msg.code(2035)
118                                                                + "GraphQL introspection not supported at instance level. Please try at server- or instance- level.");
119                        }
120
121                        operations = EnumSet.of(
122                                        GraphQLSchemaGenerator.FHIROperationType.READ, GraphQLSchemaGenerator.FHIROperationType.SEARCH);
123
124                        Collection<String> resourceTypes;
125                        if (theRequestDetails.getResourceName() != null) {
126                                resourceTypes = Collections.singleton(theRequestDetails.getResourceName());
127                        } else {
128                                resourceTypes = new HashSet<>();
129                                for (String next : myContext.getResourceNames()) {
130                                        if (myDaoRegistry.isResourceTypeSupported(next)) {
131                                                resourceTypes.add(next);
132                                        }
133                                }
134                                resourceTypes = resourceTypes.stream().sorted().collect(Collectors.toList());
135                        }
136
137                        return generateSchema(theQueryBody, resourceTypes, operations);
138                } else {
139                        return super.processGraphQlPostRequest(theServletRequestDetails, theRequestDetails, theId, theQueryBody);
140                }
141        }
142
143        private String generateSchema(
144                        String theQueryBody,
145                        Collection<String> theResourceTypes,
146                        EnumSet<GraphQLSchemaGenerator.FHIROperationType> theOperations) {
147
148                final StringBuilder schemaBuilder = new StringBuilder();
149                try (Writer writer = new StringBuilderWriter(schemaBuilder)) {
150
151                        // Generate FHIR base types schemas
152                        myGenerator.generateTypes(writer, theOperations);
153
154                        // Fix up a few things that are missing from the generated schema
155                        writer.append("\ninterface Element {")
156                                        .append("\n  id: ID")
157                                        .append("\n}")
158                                        .append("\n");
159                        //                      writer
160                        //                              .append("\ninterface Quantity {\n")
161                        //                                              .append("id: String\n")
162                        //                              .append("extension: [Extension]\n")
163                        //                              .append("value: decimal  _value: ElementBase\n")
164                        //                              .append("comparator: code  _comparator: ElementBase\n")
165                        //                              .append("unit: String  _unit: ElementBase\n")
166                        //                              .append("system: uri  _system: ElementBase\n")
167                        //                              .append("code: code  _code: ElementBase\n")
168                        //                              .append("\n}")
169                        //                              .append("\n");
170
171                        //                      writer
172                        //                              .append("\ntype Resource {")
173                        //                              .append("\n  id: [token]" + "\n}")
174                        //                              .append("\n");
175                        //                      writer
176                        //                              .append("\ninput ResourceInput {")
177                        //                              .append("\n  id: [token]" + "\n}")
178                        //                              .append("\n");
179
180                        // Generate schemas for the resource types
181                        for (String nextResourceType : theResourceTypes) {
182                                StructureDefinition sd = fetchStructureDefinition(nextResourceType);
183                                List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
184                                                .getActiveSearchParams(
185                                                                nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
186                                                .values());
187                                myGenerator.generateResource(writer, sd, parameters, theOperations);
188                        }
189
190                        // Generate queries
191                        writer.append("\ntype Query {");
192                        for (String nextResourceType : theResourceTypes) {
193                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.READ)) {
194                                        writer.append("\n  ")
195                                                        .append(nextResourceType)
196                                                        .append("(id: String): ")
197                                                        .append(nextResourceType)
198                                                        .append("\n");
199                                }
200                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.SEARCH)) {
201                                        List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
202                                                        .getActiveSearchParams(
203                                                                        nextResourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH)
204                                                        .values());
205                                        myGenerator.generateListAccessQuery(writer, parameters, nextResourceType);
206                                        myGenerator.generateConnectionAccessQuery(writer, parameters, nextResourceType);
207                                }
208                        }
209                        writer.append("\n}");
210
211                        writer.flush();
212                } catch (IOException e) {
213                        throw new InternalErrorException(Msg.code(2036) + e.getMessage(), e);
214                }
215
216                String schema = schemaBuilder.toString().replace("\r", "");
217
218                // Set these to INFO if you're testing, then set back before committing
219                ourLog.debug("Schema generated: {} chars", schema.length());
220                ourLog.debug("Schema generated: {}", msg(() -> StringUtil.prependLineNumbers(schema)));
221
222                SchemaParser schemaParser = new SchemaParser();
223                TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema);
224
225                SchemaGenerator schemaGenerator = new SchemaGenerator();
226                RuntimeWiring.Builder runtimeWiringBuilder = RuntimeWiring.newRuntimeWiring();
227                for (String next : typeDefinitionRegistry.scalars().keySet()) {
228                        if (Character.isUpperCase(next.charAt(0))) {
229                                // Skip GraphQL built-in types
230                                continue;
231                        }
232                        runtimeWiringBuilder.scalar(new GraphQLScalarType.Builder()
233                                        .name(next)
234                                        .coercing(new GraphqlStringCoercing())
235                                        .build());
236                }
237
238                for (InterfaceTypeDefinition next : typeDefinitionRegistry.getTypes(InterfaceTypeDefinition.class)) {
239                        TypeResolver resolver = new TypeResolverProxy();
240                        TypeRuntimeWiring wiring = TypeRuntimeWiring.newTypeWiring(next.getName())
241                                        .typeResolver(resolver)
242                                        .build();
243                        runtimeWiringBuilder.type(wiring);
244                }
245
246                RuntimeWiring runtimeWiring = runtimeWiringBuilder.build();
247                GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
248
249                GraphQL build = GraphQL.newGraphQL(graphQLSchema).build();
250                ExecutionResult executionResult = build.execute(theQueryBody);
251
252                Map<String, Object> data = executionResult.toSpecification();
253                Gson gson = new GsonBuilder().create();
254                return gson.toJson(data);
255        }
256
257        @Nonnull
258        private List<SearchParameter> toR5SearchParams(Collection<RuntimeSearchParam> searchParams) {
259                List<SearchParameter> parameters = new ArrayList<>();
260                for (RuntimeSearchParam next : searchParams) {
261                        SearchParameter sp = toR5SearchParam(next);
262                        if (sp != null) {
263                                parameters.add(sp);
264                        }
265                }
266                return parameters;
267        }
268
269        @Nullable
270        private SearchParameter toR5SearchParam(RuntimeSearchParam next) {
271                SearchParameter sp = new SearchParameter();
272                sp.setUrl(next.getUri());
273                sp.setCode(next.getName());
274                sp.setName(next.getName());
275
276                switch (next.getParamType()) {
277                        case NUMBER:
278                                sp.setType(Enumerations.SearchParamType.NUMBER);
279                                break;
280                        case DATE:
281                                sp.setType(Enumerations.SearchParamType.DATE);
282                                break;
283                        case STRING:
284                                sp.setType(Enumerations.SearchParamType.STRING);
285                                break;
286                        case TOKEN:
287                                sp.setType(Enumerations.SearchParamType.TOKEN);
288                                break;
289                        case REFERENCE:
290                                sp.setType(Enumerations.SearchParamType.REFERENCE);
291                                break;
292                        case COMPOSITE:
293                                sp.setType(Enumerations.SearchParamType.COMPOSITE);
294                                break;
295                        case QUANTITY:
296                                sp.setType(Enumerations.SearchParamType.QUANTITY);
297                                break;
298                        case URI:
299                                sp.setType(Enumerations.SearchParamType.URI);
300                                break;
301                        case HAS:
302                        case SPECIAL:
303                        default:
304                                return null;
305                }
306                return sp;
307        }
308
309        @Nonnull
310        private StructureDefinition fetchStructureDefinition(String resourceName) {
311                StructureDefinition retVal = myContext.fetchResource(
312                                StructureDefinition.class, "http://hl7.org/fhir/StructureDefinition/" + resourceName);
313                Validate.notNull(retVal);
314                return retVal;
315        }
316}