SchemaFileService.java

package io.github.deweyjose.graphqlcodegen.services;

import io.github.deweyjose.graphqlcodegen.parameters.IntrospectionRequest;
import io.github.deweyjose.graphqlcodegen.services.RemoteSchemaService.IntrospectionOperation;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import org.apache.maven.artifact.Artifact;

/** Service for managing schema files. */
@Getter
@Setter
public class SchemaFileService {
  private final File outputDir;
  private final SchemaManifestService manifest;
  private final RemoteSchemaService remoteSchemaService;

  private Set<File> schemaPaths;
  private List<File> schemaJarFilesFromDependencies;

  /**
   * Constructs a new SchemaFileService with the given output directory and manifest.
   *
   * @param outputDir the output directory to save the schema files
   * @param manifest the manifest service to use
   */
  public SchemaFileService(File outputDir, SchemaManifestService manifest) {
    this(outputDir, manifest, new RemoteSchemaService());
  }

  /**
   * Constructs a new SchemaFileService with the given output directory, manifest, and remote schema
   * service.
   *
   * @param outputDir the output directory to save the schema files
   * @param manifest the manifest service to use
   * @param remoteSchemaService the remote schema service to use
   */
  public SchemaFileService(
      File outputDir, SchemaManifestService manifest, RemoteSchemaService remoteSchemaService) {
    this.schemaPaths = new HashSet<>();
    this.outputDir = outputDir;
    this.manifest = manifest;
    this.remoteSchemaService = remoteSchemaService;
  }

  /**
   * Loads the schema paths, expanding directories to include all GraphQL schema files within them.
   *
   * @param schemaPaths the collection of files or directories to load as schema paths
   */
  public void loadExpandedSchemaPaths(Collection<File> schemaPaths) {
    setSchemaPaths(
        schemaPaths.stream()
            .map(
                path -> {
                  if (path.isFile()) {
                    return Stream.of(path);
                  } else {
                    return findGraphQLSFiles(path).stream();
                  }
                })
            .flatMap(stream -> stream)
            .collect(Collectors.toSet()));
  }

  /**
   * Loads the schema jar files from dependencies into the internal list.
   *
   * @param artifacts the set of Maven artifacts (dependencies)
   * @param schemaJarFilesFromDependencies the set of dependency coordinates to extract schema jars
   *     from
   */
  public void loadSchemaJarFilesFromDependencies(
      Set<Artifact> artifacts, Set<String> schemaJarFilesFromDependencies) {
    this.schemaJarFilesFromDependencies =
        extractSchemaFilesFromDependencies(artifacts, schemaJarFilesFromDependencies);
  }

  /**
   * Loads remote schema URLs and saves them as files in the output directory, adding them to
   * schemaPaths.
   *
   * @param schemaUrls the list of schema URLs to load
   */
  @SneakyThrows
  public void loadSchemaUrls(List<String> schemaUrls) {
    for (String url : schemaUrls) {
      String content = remoteSchemaService.getRemoteSchemaFile(url);
      schemaPaths.add(saveUrlToFile(url, content));
    }
  }

  /**
   * Loads introspected schemas from the given collection of IntrospectionRequest objects.
   *
   * @param schemaUrls the collection of IntrospectionRequest objects to load
   */
  @SneakyThrows
  public void loadIntrospectedSchemas(Collection<IntrospectionRequest> schemaUrls) {
    for (IntrospectionRequest request : schemaUrls) {
      String query = Optional.ofNullable(request.getQuery()).orElse(Constants.DEFAULT_QUERY);
      String operationName =
          Optional.ofNullable(request.getOperationName()).orElse(Constants.DEFAULT_OPERATION_NAME);
      IntrospectionOperation operation =
          IntrospectionOperation.builder().query(query).operationName(operationName).build();
      String content =
          remoteSchemaService.getIntrospectedSchemaFile(
              request.getUrl(), operation, request.getHeaders());
      schemaPaths.add(saveUrlToFile(request.getUrl(), content));
    }
  }

  /**
   * Checks if there are any schema files or schema jars to generate. Throws if none are found.
   *
   * @throws IllegalArgumentException if no schema files or jars are found
   */
  public void checkHasSchemaFiles() {
    if (getSchemaPaths().isEmpty()
        && Optional.ofNullable(getSchemaJarFilesFromDependencies())
            .orElse(Collections.emptyList())
            .isEmpty()) {
      throw new IllegalArgumentException("No schema files found. Please check your configuration.");
    }
  }

  /**
   * Returns true if there are no schema files or schema jars to process.
   *
   * @return true if there is no work to do, false otherwise
   */
  public boolean noWorkToDo() {
    return getSchemaPaths().isEmpty() && getSchemaJarFilesFromDependencies().isEmpty();
  }

  /** Syncs the manifest by writing the current state to disk. Prints stack trace on error. */
  public void syncManifest() {
    try {
      manifest.syncManifest();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  /** Filters schemaPaths to only include files that have changed according to the manifest. */
  public void filterChangedSchemaFiles() {
    manifest.setFiles(new HashSet<>(schemaPaths));
    Set<File> changed = new HashSet<>(schemaPaths);
    changed.retainAll(manifest.getChangedFiles());
    setSchemaPaths(changed);
  }

  /**
   * Fetches the contents of a remote schema from the given URL as a string.
   *
   * @param url the URL to fetch the schema from
   * @return the schema SDL as a string
   * @throws IOException if an I/O error occurs
   * @throws InterruptedException if the thread is interrupted
   */
  public String fetchSchema(String url) throws IOException, InterruptedException {
    return remoteSchemaService.getRemoteSchemaFile(url);
  }

  /**
   * Downloads a remote schema from the given URL and saves it as a file in the output directory.
   *
   * @param url the URL to download the schema from
   * @param outputDir the output directory to save the file in
   * @return the File object representing the saved schema
   * @throws IOException if an I/O error occurs
   * @throws InterruptedException if the thread is interrupted
   */
  private File saveUrlToFile(String url, String content) throws IOException, InterruptedException {
    String fileName =
        "remote-schemas/" + Base64.getEncoder().encodeToString(url.getBytes()) + ".graphqls";
    File outFile = new File(outputDir, fileName);
    Files.createDirectories(outFile.getParentFile().toPath());
    Files.writeString(outFile.toPath(), content);
    return outFile;
  }

  /**
   * Recursively finds all GraphQL schema files in a directory and its subdirectories.
   *
   * @param directory the directory to search
   * @return a set of GraphQL schema files found
   */
  public static Set<File> findGraphQLSFiles(File directory) {
    Set<File> result = new HashSet<>();

    File[] contents = directory.listFiles();
    if (contents != null) {
      for (File content : contents) {
        if (content.isFile() && isGraphqlFile(content)) {
          result.add(content);
        } else if (content.isDirectory()) {
          Set<File> subdirectoryGraphQLSFiles = findGraphQLSFiles(content);
          result.addAll(subdirectoryGraphQLSFiles);
        }
      }
    }

    return result;
  }

  /**
   * Returns true if the file is a GraphQL schema file (.graphql, .graphqls, .gqls).
   *
   * @param file the file to check
   * @return true if the file is a GraphQL schema file, false otherwise
   */
  public static boolean isGraphqlFile(File file) {
    return file.getName().endsWith(".graphqls")
        || file.getName().endsWith(".graphql")
        || file.getName().endsWith(".gqls");
  }

  /**
   * Extracts schema files from the given set of dependency artifacts, matching the provided
   * dependency coordinates.
   *
   * @param dependencyArtifacts the set of Maven dependency artifacts
   * @param schemaJarFilesFromDependencies the collection of dependency coordinates to match
   * @return a list of schema files (jars) found in the dependencies
   */
  public static List<File> extractSchemaFilesFromDependencies(
      Set<Artifact> dependencyArtifacts, Collection<String> schemaJarFilesFromDependencies) {
    return schemaJarFilesFromDependencies.stream()
        .map(String::trim)
        .filter(jarDep -> !jarDep.isEmpty())
        .map(jarDep -> findArtifactFromDependencies(dependencyArtifacts, jarDep))
        .filter(Optional::isPresent)
        .map(Optional::get)
        .map(Artifact::getFile)
        .toList();
  }

  /**
   * Finds a Maven artifact in the given set of dependencies matching the provided coordinate
   * string.
   *
   * @param dependencyArtifacts the set of Maven dependency artifacts
   * @param artifactRef the Maven coordinate string (groupId:artifactId:version)
   * @return an Optional containing the matching Artifact, or empty if not found
   */
  private static Optional<Artifact> findArtifactFromDependencies(
      Set<Artifact> dependencyArtifacts, final String artifactRef) {
    final String cleanRef = artifactRef.trim();

    for (final Artifact artifact : dependencyArtifacts) {
      final String ref =
          String.format(
              "%s:%s:%s", artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion());

      if (ref.equals(cleanRef)) {
        return java.util.Optional.of(artifact);
      }
    }
    return Optional.empty();
  }
}