001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package dev.metaschema.core.model; 007 008import com.github.benmanes.caffeine.cache.Caffeine; 009 010import org.apache.logging.log4j.LogManager; 011import org.apache.logging.log4j.Logger; 012 013import java.io.IOException; 014import java.net.MalformedURLException; 015import java.net.URI; 016import java.net.URISyntaxException; 017import java.net.URL; 018import java.nio.file.Path; 019import java.util.Collection; 020import java.util.Deque; 021import java.util.LinkedList; 022import java.util.Map; 023import java.util.concurrent.TimeUnit; 024import java.util.stream.Collectors; 025 026import dev.metaschema.core.util.CollectionUtil; 027import dev.metaschema.core.util.ObjectUtils; 028import edu.umd.cs.findbugs.annotations.NonNull; 029 030/** 031 * Base implementation of {@link ILoader} providing resource loading with 032 * caching and cycle detection. 033 * <p> 034 * This loader maintains a cache of loaded resources keyed by URI to avoid 035 * redundant parsing. It tracks visited resources during import chains to detect 036 * and prevent circular dependencies. 037 * 038 * @param <T> 039 * the Java type of the resource being loaded 040 */ 041public abstract class AbstractLoader<T> implements ILoader<T> { 042 private static final Logger LOGGER = LogManager.getLogger(AbstractLoader.class); 043 044 @NonNull 045 private final Map<URI, T> cache = ObjectUtils.notNull(Caffeine.newBuilder() 046 .maximumSize(100) 047 .expireAfterAccess(10, TimeUnit.MINUTES) 048 .<URI, T>build().asMap()); 049 050 @Override 051 @NonNull 052 public Collection<T> getLoadedResources() { 053 return CollectionUtil.unmodifiableCollection(ObjectUtils.notNull(cache.values())); 054 } 055 056 /** 057 * Retrieve a mapping of resource URIs to the associated loaded resource. 058 * 059 * @return the mapping 060 */ 061 @NonNull 062 protected Map<URI, T> getCachedEntries() { 063 return CollectionUtil.unmodifiableMap(cache); 064 } 065 066 @Override 067 @NonNull 068 public T load(@NonNull URI resource) throws MetaschemaException, IOException { 069 if (!resource.isAbsolute()) { 070 throw new IllegalArgumentException(String.format("The URI '%s' must be absolute.", resource.toString())); 071 } 072 return loadInternal(resource, new LinkedList<>()); 073 } 074 075 /** 076 * Load a resource from the specified path. 077 * 078 * @param path 079 * the resource to load 080 * @return the loaded instance for the specified resource 081 * @throws MetaschemaException 082 * if an error occurred while processing the resource 083 * @throws IOException 084 * if an error occurred parsing the resource 085 */ 086 @Override 087 @NonNull 088 public T load(@NonNull Path path) throws MetaschemaException, IOException { 089 // use toURL to normalize the URI 090 return load(ObjectUtils.notNull(path.toAbsolutePath().normalize().toUri().toURL())); 091 } 092 093 /** 094 * Loads a resource from the specified URL. 095 * 096 * @param url 097 * the URL to load the resource from 098 * @return the loaded instance for the specified resource 099 * @throws MetaschemaException 100 * if an error occurred while processing the resource 101 * @throws IOException 102 * if an error occurred parsing the resource 103 */ 104 @Override 105 @NonNull 106 public T load(@NonNull URL url) throws MetaschemaException, IOException { 107 try { 108 URI resource = url.toURI(); 109 return loadInternal(ObjectUtils.notNull(resource), new LinkedList<>()); 110 } catch (URISyntaxException ex) { 111 // this should not happen 112 LOGGER.error("Invalid url", ex); 113 throw new IOException(ex); 114 } 115 } 116 117 /** 118 * Loads a resource from the provided URI. 119 * <p> 120 * If the resource imports other resources, the provided 121 * {@code visitedResources} can be used to track circular imports. This is 122 * useful when this method recurses into included resources. 123 * <p> 124 * Previously loaded resources are provided by the cache. This method will add 125 * the resource to the cache after all imported resources have been loaded. 126 * 127 * @param resource 128 * the resource to load 129 * @param visitedResources 130 * a LIFO queue representing previously visited resources in an import 131 * chain 132 * @return the loaded resource 133 * @throws MetaschemaException 134 * if an error occurred while processing the resource 135 * @throws MalformedURLException 136 * if the provided URI is malformed 137 * @throws IOException 138 * if an error occurred parsing the resource 139 */ 140 @NonNull 141 protected T loadInternal(@NonNull URI resource, @NonNull Deque<URI> visitedResources) 142 throws MetaschemaException, MalformedURLException, IOException { 143 // first check if the current resource has been visited to prevent cycles 144 if (visitedResources.contains(resource)) { 145 throw new MetaschemaException("Cycle detected in metaschema includes for '" + resource + "'. Call stack: '" 146 + visitedResources.stream().map(URI::toString).collect(Collectors.joining(","))); 147 } 148 149 T retval = cache.get(resource); 150 if (retval == null) { 151 LOGGER.info("Loading '{}'", resource); 152 153 try { 154 visitedResources.push(resource); 155 retval = parseResource(resource, visitedResources); 156 } finally { 157 visitedResources.pop(); 158 } 159 cache.put(resource, retval); 160 } else if (LOGGER.isDebugEnabled()) { 161 LOGGER.debug("Found resource in cache '{}'", resource); 162 } 163 return ObjectUtils.notNull(retval); 164 } 165 166 /** 167 * Parse the provided {@code resource}. 168 * 169 * @param resource 170 * the resource to parse 171 * @param visitedResources 172 * a stack representing previously parsed resources imported by the 173 * provided {@code resource} 174 * @return the parsed resource 175 * @throws IOException 176 * if an error occurred while parsing the resource 177 */ 178 protected abstract T parseResource(@NonNull URI resource, @NonNull Deque<URI> visitedResources) 179 throws IOException; 180 181}