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(nextResourceType)
185                                                .values());
186                                myGenerator.generateResource(writer, sd, parameters, theOperations);
187                        }
188
189                        // Generate queries
190                        writer.append("\ntype Query {");
191                        for (String nextResourceType : theResourceTypes) {
192                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.READ)) {
193                                        writer.append("\n  ")
194                                                        .append(nextResourceType)
195                                                        .append("(id: String): ")
196                                                        .append(nextResourceType)
197                                                        .append("\n");
198                                }
199                                if (theOperations.contains(GraphQLSchemaGenerator.FHIROperationType.SEARCH)) {
200                                        List<SearchParameter> parameters = toR5SearchParams(mySearchParamRegistry
201                                                        .getActiveSearchParams(nextResourceType)
202                                                        .values());
203                                        myGenerator.generateListAccessQuery(writer, parameters, nextResourceType);
204                                        myGenerator.generateConnectionAccessQuery(writer, parameters, nextResourceType);
205                                }
206                        }
207                        writer.append("\n}");
208
209                        writer.flush();
210                } catch (IOException e) {
211                        throw new InternalErrorException(Msg.code(2036) + e.getMessage(), e);
212                }
213
214                String schema = schemaBuilder.toString().replace("\r", "");
215
216                // Set these to INFO if you're testing, then set back before committing
217                ourLog.debug("Schema generated: {} chars", schema.length());
218                ourLog.debug("Schema generated: {}", msg(() -> StringUtil.prependLineNumbers(schema)));
219
220                SchemaParser schemaParser = new SchemaParser();
221                TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema);
222
223                SchemaGenerator schemaGenerator = new SchemaGenerator();
224                RuntimeWiring.Builder runtimeWiringBuilder = RuntimeWiring.newRuntimeWiring();
225                for (String next : typeDefinitionRegistry.scalars().keySet()) {
226                        if (Character.isUpperCase(next.charAt(0))) {
227                                // Skip GraphQL built-in types
228                                continue;
229                        }
230                        runtimeWiringBuilder.scalar(new GraphQLScalarType.Builder()
231                                        .name(next)
232                                        .coercing(new GraphqlStringCoercing())
233                                        .build());
234                }
235
236                for (InterfaceTypeDefinition next : typeDefinitionRegistry.getTypes(InterfaceTypeDefinition.class)) {
237                        TypeResolver resolver = new TypeResolverProxy();
238                        TypeRuntimeWiring wiring = TypeRuntimeWiring.newTypeWiring(next.getName())
239                                        .typeResolver(resolver)
240                                        .build();
241                        runtimeWiringBuilder.type(wiring);
242                }
243
244                RuntimeWiring runtimeWiring = runtimeWiringBuilder.build();
245                GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
246
247                GraphQL build = GraphQL.newGraphQL(graphQLSchema).build();
248                ExecutionResult executionResult = build.execute(theQueryBody);
249
250                Map<String, Object> data = executionResult.toSpecification();
251                Gson gson = new GsonBuilder().create();
252                return gson.toJson(data);
253        }
254
255        @Nonnull
256        private List<SearchParameter> toR5SearchParams(Collection<RuntimeSearchParam> searchParams) {
257                List<SearchParameter> parameters = new ArrayList<>();
258                for (RuntimeSearchParam next : searchParams) {
259                        SearchParameter sp = toR5SearchParam(next);
260                        if (sp != null) {
261                                parameters.add(sp);
262                        }
263                }
264                return parameters;
265        }
266
267        @Nullable
268        private SearchParameter toR5SearchParam(RuntimeSearchParam next) {
269                SearchParameter sp = new SearchParameter();
270                sp.setUrl(next.getUri());
271                sp.setCode(next.getName());
272                sp.setName(next.getName());
273
274                switch (next.getParamType()) {
275                        case NUMBER:
276                                sp.setType(Enumerations.SearchParamType.NUMBER);
277                                break;
278                        case DATE:
279                                sp.setType(Enumerations.SearchParamType.DATE);
280                                break;
281                        case STRING:
282                                sp.setType(Enumerations.SearchParamType.STRING);
283                                break;
284                        case TOKEN:
285                                sp.setType(Enumerations.SearchParamType.TOKEN);
286                                break;
287                        case REFERENCE:
288                                sp.setType(Enumerations.SearchParamType.REFERENCE);
289                                break;
290                        case COMPOSITE:
291                                sp.setType(Enumerations.SearchParamType.COMPOSITE);
292                                break;
293                        case QUANTITY:
294                                sp.setType(Enumerations.SearchParamType.QUANTITY);
295                                break;
296                        case URI:
297                                sp.setType(Enumerations.SearchParamType.URI);
298                                break;
299                        case HAS:
300                        case SPECIAL:
301                        default:
302                                return null;
303                }
304                return sp;
305        }
306
307        @Nonnull
308        private StructureDefinition fetchStructureDefinition(String resourceName) {
309                StructureDefinition retVal = myContext.fetchResource(
310                                StructureDefinition.class, "http://hl7.org/fhir/StructureDefinition/" + resourceName);
311                Validate.notNull(retVal);
312                return retVal;
313        }
314}