
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}