diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationDescriptor.java b/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationDescriptor.java index 1646313d48b..ab45168268c 100644 --- a/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationDescriptor.java +++ b/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationDescriptor.java @@ -31,9 +31,12 @@ public final class ApplicationDescriptor private final String url; + private final ApplicationType type; + private ApplicationDescriptor( final Builder builder ) { this.key = requireNonNull( builder.key, "key cannot be null" ); + this.type = builder.type != null ? builder.type : ApplicationType.BUNDLE; this.description = builder.description != null ? builder.description : ""; this.descriptionI18nKey = builder.descriptionI18nKey; this.icon = builder.icon; @@ -50,6 +53,11 @@ public ApplicationKey getKey() return key; } + public ApplicationType getType() + { + return type; + } + public String getDescription() { return description; @@ -109,16 +117,17 @@ public boolean equals( final Object o ) final ApplicationDescriptor that = (ApplicationDescriptor) o; - return key.equals( that.key ) && Objects.equals( description, that.description ) && Objects.equals( icon, that.icon ) && - Objects.equals( descriptionI18nKey, that.descriptionI18nKey ) && Objects.equals( title, that.title ) && - Objects.equals( titleI18nKey, that.titleI18nKey ) && Objects.equals( vendorName, that.vendorName ) && - Objects.equals( vendorUrl, that.vendorUrl ) && Objects.equals( url, that.url ) && schemaConfig.equals( that.schemaConfig ); + return key.equals( that.key ) && type == that.type && Objects.equals( description, that.description ) && + Objects.equals( icon, that.icon ) && Objects.equals( descriptionI18nKey, that.descriptionI18nKey ) && + Objects.equals( title, that.title ) && Objects.equals( titleI18nKey, that.titleI18nKey ) && + Objects.equals( vendorName, that.vendorName ) && Objects.equals( vendorUrl, that.vendorUrl ) && + Objects.equals( url, that.url ) && schemaConfig.equals( that.schemaConfig ); } @Override public int hashCode() { - return Objects.hash( key, description, icon, title, titleI18nKey, vendorName, vendorUrl, url, schemaConfig ); + return Objects.hash( key, type, description, icon, title, titleI18nKey, vendorName, vendorUrl, url, schemaConfig ); } public static Builder create() @@ -146,6 +155,8 @@ public static final class Builder private String url; + private ApplicationType type; + private final GenericValue.ObjectBuilder schemaConfig = GenericValue.newObject(); private Builder() @@ -158,6 +169,12 @@ public Builder key( final ApplicationKey key ) return this; } + public Builder type( final ApplicationType type ) + { + this.type = type; + return this; + } + public Builder description( final String description ) { this.description = description; diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationType.java b/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationType.java new file mode 100644 index 00000000000..71af4b25342 --- /dev/null +++ b/modules/core/core-api/src/main/java/com/enonic/xp/app/ApplicationType.java @@ -0,0 +1,6 @@ +package com.enonic.xp.app; + +public enum ApplicationType +{ + STATIC, BUNDLE +} diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/schema/SchemaNodePropertyNames.java b/modules/core/core-api/src/main/java/com/enonic/xp/schema/SchemaNodePropertyNames.java index b1de3590a7f..8ba934d6b5e 100644 --- a/modules/core/core-api/src/main/java/com/enonic/xp/schema/SchemaNodePropertyNames.java +++ b/modules/core/core-api/src/main/java/com/enonic/xp/schema/SchemaNodePropertyNames.java @@ -4,6 +4,10 @@ public final class SchemaNodePropertyNames { public static final String RESOURCE = "resource"; + public static final String MIME_TYPE = "mimeType"; + + public static final String ICON = "icon"; + private SchemaNodePropertyNames() { diff --git a/modules/core/core-api/src/test/java/com/enonic/xp/app/ApplicationDescriptorTest.java b/modules/core/core-api/src/test/java/com/enonic/xp/app/ApplicationDescriptorTest.java index 74241b1a985..7e6a61ea147 100644 --- a/modules/core/core-api/src/test/java/com/enonic/xp/app/ApplicationDescriptorTest.java +++ b/modules/core/core-api/src/test/java/com/enonic/xp/app/ApplicationDescriptorTest.java @@ -31,6 +31,30 @@ void getters() assertSame( icon, desc.getIcon() ); } + @Test + void default_type_is_bundle() + { + final ApplicationDescriptor desc = ApplicationDescriptor.create().key( ApplicationKey.from( "app" ) ).build(); + + assertEquals( ApplicationType.BUNDLE, desc.getType() ); + } + + @Test + void type() + { + final ApplicationKey key = ApplicationKey.from( "app" ); + + final ApplicationDescriptor staticDesc = ApplicationDescriptor.create().key( key ).type( ApplicationType.STATIC ).build(); + final ApplicationDescriptor bundleDesc = ApplicationDescriptor.create().key( key ).type( ApplicationType.BUNDLE ).build(); + final ApplicationDescriptor defaultDesc = ApplicationDescriptor.create().key( key ).build(); + + assertEquals( ApplicationType.STATIC, staticDesc.getType() ); + assertEquals( ApplicationType.BUNDLE, bundleDesc.getType() ); + assertEquals( bundleDesc, defaultDesc ); + assertNotEquals( staticDesc, bundleDesc ); + assertNotEquals( staticDesc.hashCode(), bundleDesc.hashCode() ); + } + @Test void null_description() { diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfo.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfo.java index 56876cde043..f51d245e58d 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfo.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfo.java @@ -1,9 +1,15 @@ package com.enonic.xp.core.impl.app; +import com.enonic.xp.app.ApplicationType; + public class AppInfo { public String name; + public ApplicationType type = ApplicationType.BUNDLE; + + public boolean hasCmsDescriptor; + public String title; public String vendorName; diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfoResolver.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfoResolver.java index 82f0034107b..e4b2c8316c8 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfoResolver.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppInfoResolver.java @@ -43,6 +43,7 @@ private static AppInfo findAppInfo( final Path file ) { final Manifest man; final String descriptorYaml; + final boolean hasCmsDescriptor; try (JarFile jarFile = new JarFile( file.toFile() )) { @@ -60,8 +61,10 @@ private static AppInfo findAppInfo( final Path file ) throw new ApplicationInvalidException( "Not a valid application." ); } + hasCmsDescriptor = SchemaResourcePaths.CMS_DESCRIPTOR_PATHS.stream().anyMatch( path -> jarFile.getJarEntry( path ) != null ); } final AppInfo appInfo = new AppInfo(); + appInfo.hasCmsDescriptor = hasCmsDescriptor; final Attributes attrs = man.getMainAttributes(); appInfo.name = attrs.getValue( Constants.BUNDLE_SYMBOLICNAME ); appInfo.version = Optional.ofNullable( attrs.getValue( Constants.BUNDLE_VERSION ) ).orElse( "0.0.0" ); @@ -83,6 +86,7 @@ private static AppInfo findAppInfo( final Path file ) YmlApplicationDescriptorParser.parse( descriptorYaml, ApplicationKey.from( appInfo.name ) ).build(); appInfo.title = descriptor.getTitle(); appInfo.vendorName = descriptor.getVendorName(); + appInfo.type = descriptor.getType(); } else { diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppSchemaResolver.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppSchemaResolver.java new file mode 100644 index 00000000000..13e07bd0605 --- /dev/null +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/AppSchemaResolver.java @@ -0,0 +1,79 @@ +package com.enonic.xp.core.impl.app; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import com.google.common.io.ByteSource; + +/** + * Extracts schema resources (see {@link SchemaResourcePaths}) from an application jar. + * Keys of the returned map are paths relative to the {@code cms/} root, values are the resource contents. + */ +final class AppSchemaResolver +{ + private AppSchemaResolver() + { + } + + static Map resolve( final ByteSource byteSource ) + { + final Map resources = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream( byteSource.openBufferedStream() )) + { + ZipEntry entry; + while ( ( entry = zip.getNextEntry() ) != null ) + { + if ( entry.isDirectory() ) + { + continue; + } + + final Matcher matcher = SchemaResourcePaths.SCHEMA_RESOURCE_PATTERN.matcher( entry.getName() ); + if ( !matcher.matches() ) + { + continue; + } + + final ByteSource content = ByteSource.wrap( zip.readAllBytes() ); + + final String verbatimPath = firstNonNull( matcher.group( SchemaResourcePaths.PHRASES_PATH_GROUP ), + matcher.group( SchemaResourcePaths.ICON_PATH_GROUP ) ); + if ( verbatimPath != null ) + { + resources.put( verbatimPath, content ); + } + else + { + // Both .yaml and .yml descriptors normalize to the same ".yaml" key. + // If a JAR contains both variants, .yaml wins regardless of zip entry order: + // put() lets .yaml overwrite, putIfAbsent() keeps .yml from replacing it. + final String path = matcher.group( SchemaResourcePaths.DESCRIPTOR_PATH_GROUP ) + ".yaml"; + + if ( "yaml".equals( matcher.group( SchemaResourcePaths.EXTENSION_GROUP ) ) ) + { + resources.put( path, content ); + } + else + { + resources.putIfAbsent( path, content ); + } + } + } + } + catch ( IOException e ) + { + throw new UncheckedIOException( e ); + } + return resources; + } + + private static String firstNonNull( final String first, final String second ) + { + return first != null ? first : second; + } +} \ No newline at end of file diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationFactory.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationFactory.java index f77e3cb5b27..6e36c3fb714 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationFactory.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationFactory.java @@ -5,21 +5,33 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.function.Predicate; +import java.util.function.Supplier; import org.osgi.framework.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Suppliers; import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationType; import com.enonic.xp.core.impl.app.resolver.ApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.BundleApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.ClassLoaderApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.FakeCmsYamlUrlResolver; +import com.enonic.xp.core.impl.app.resolver.FilteredApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.MultiApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.NodeResourceApplicationUrlResolver; +import com.enonic.xp.node.NodeName; +import com.enonic.xp.node.NodePath; import com.enonic.xp.node.NodeService; import com.enonic.xp.server.RunMode; public final class ApplicationFactory { + private static final Logger LOG = LoggerFactory.getLogger( ApplicationFactory.class ); + private final NodeService nodeService; private final AppConfig appConfig; @@ -42,24 +54,55 @@ ApplicationUrlResolver createUrlResolver( final Bundle bundle, final String sour return createUrlResolverBySource( bundle, source ); } - final BundleApplicationUrlResolver bundleUrlResolver = new BundleApplicationUrlResolver( bundle ); final ApplicationKey appKey = ApplicationHelper.getApplicationKey( bundle ); - final NodeResourceApplicationUrlResolver nodeResourceApplicationResolver = - new NodeResourceApplicationUrlResolver( appKey, nodeService ); - final ClassLoaderApplicationUrlResolver classLoaderUrlResolver = createClassLoaderUrlResolver( bundle ); - final FakeCmsYamlUrlResolver fakeSiteXmlUrlResolver = new FakeCmsYamlUrlResolver( appKey, nodeService ); + final ApplicationUrlResolver bundleUrlResolver = createBundleUrlResolver( bundle ); - final boolean addCLR = RunMode.isDev() && classLoaderUrlResolver != null; + final ApplicationUrlResolver appUrlResolver = hasNodeBackedSchema( bundle, appKey ) + // schema resources are served from nodes below the application node in system-repo, + // the bundle's own schema resources are hidden as soon as the persisted schema exists. + ? new MultiApplicationUrlResolver( createStaticAppNodeResolver( appKey ), + new FilteredApplicationUrlResolver( bundleUrlResolver, () -> schemaResourceFilter( appKey ) ) ) + : bundleUrlResolver; if ( appConfig.virtual_enabled() && appConfig.virtual_schema_override() ) { - return addCLR ? new MultiApplicationUrlResolver( nodeResourceApplicationResolver, classLoaderUrlResolver, bundleUrlResolver, - fakeSiteXmlUrlResolver ) - : new MultiApplicationUrlResolver( nodeResourceApplicationResolver, bundleUrlResolver, fakeSiteXmlUrlResolver ); + return new MultiApplicationUrlResolver( NodeResourceApplicationUrlResolver.forVirtualApp( appKey, nodeService ), + appUrlResolver, new FakeCmsYamlUrlResolver( appKey, nodeService ) ); } else { - return addCLR ? new MultiApplicationUrlResolver( classLoaderUrlResolver, bundleUrlResolver ) : bundleUrlResolver; + return appUrlResolver; + } + } + + /** + * The schema of an application lives in nodes when the bundle owns it ({@code type: Static} or shipping {@code cms/cms.yaml}) + * or when a schema persisted by an earlier version still exists (the bundle then contributes logic only). + * A local application shipping {@code cms/cms.yaml} is the exception: it is never persisted and must not be shadowed + * by the schema persisted for a global installation of the same application, so its schema comes from the bundle only. + */ + private boolean hasNodeBackedSchema( final Bundle bundle, final ApplicationKey appKey ) + { + final boolean hasCmsDescriptor = ApplicationHelper.hasCmsDescriptor( bundle ); + + if ( hasCmsDescriptor && ApplicationHelper.isLocalApplication( bundle ) ) + { + return false; + } + + if ( hasCmsDescriptor || ApplicationHelper.getApplicationType( bundle ) == ApplicationType.STATIC ) + { + return true; + } + + try + { + return schemaNodeExists( appKey ); + } + catch ( Exception e ) + { + LOG.debug( "Unable to check persisted schema of [{}], assuming none", appKey, e ); + return false; } } @@ -68,23 +111,53 @@ ApplicationUrlResolver createUrlResolverBySource( final Bundle bundle, final Str switch ( source ) { case "bundle": - final ClassLoaderApplicationUrlResolver classLoaderUrlResolver = createClassLoaderUrlResolver( bundle ); - final boolean addCLR = RunMode.isDev() && classLoaderUrlResolver != null; - - return addCLR - ? new MultiApplicationUrlResolver( classLoaderUrlResolver, new BundleApplicationUrlResolver( bundle ) ) - : new BundleApplicationUrlResolver( bundle ); + return createBundleUrlResolver( bundle ); case "virtual": if ( !appConfig.virtual_enabled() ) { throw new IllegalStateException( "virtual apps are disabled" ); } - return new NodeResourceApplicationUrlResolver( ApplicationHelper.getApplicationKey( bundle ), nodeService ); + return NodeResourceApplicationUrlResolver.forVirtualApp( ApplicationHelper.getApplicationKey( bundle ), nodeService ); default: throw new IllegalArgumentException( "invalid application resolver source: " + source ); } } + private ApplicationUrlResolver createBundleUrlResolver( final Bundle bundle ) + { + final BundleApplicationUrlResolver bundleUrlResolver = new BundleApplicationUrlResolver( bundle ); + final ClassLoaderApplicationUrlResolver classLoaderUrlResolver = createClassLoaderUrlResolver( bundle ); + + return RunMode.isDev() && classLoaderUrlResolver != null + ? new MultiApplicationUrlResolver( classLoaderUrlResolver, bundleUrlResolver ) + : bundleUrlResolver; + } + + private NodeResourceApplicationUrlResolver createStaticAppNodeResolver( final ApplicationKey applicationKey ) + { + return new NodeResourceApplicationUrlResolver( applicationKey, nodeService, staticAppNodePath( applicationKey ), + ApplicationHelper::createAdminContext ); + } + + // Schema resources (descriptors and i18n phrases) must not be contributed by the bundle + // when the persisted schema (cms node below the application node) exists in system-repo + private Predicate schemaResourceFilter( final ApplicationKey applicationKey ) + { + final Supplier schemaNodeExists = Suppliers.memoize( () -> schemaNodeExists( applicationKey ) ); + return path -> !( SchemaResourcePaths.isSchemaResourcePath( path ) && schemaNodeExists.get() ); + } + + private boolean schemaNodeExists( final ApplicationKey applicationKey ) + { + final NodePath cmsPath = new NodePath( staticAppNodePath( applicationKey ), NodeName.from( VirtualAppConstants.CMS_ROOT_NAME ) ); + return ApplicationHelper.runAsAdmin( () -> nodeService.nodeExists( cmsPath ) ); + } + + private static NodePath staticAppNodePath( final ApplicationKey applicationKey ) + { + return new NodePath( ApplicationRepoServiceImpl.APPLICATION_PATH, NodeName.from( applicationKey.getName() ) ); + } + private ClassLoaderApplicationUrlResolver createClassLoaderUrlResolver( final Bundle bundle ) { final List sourcePaths = ApplicationHelper.getSourcePaths( bundle ); diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationHelper.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationHelper.java index 61e4d63ca60..bcb0a88e03f 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationHelper.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationHelper.java @@ -1,5 +1,8 @@ package com.enonic.xp.core.impl.app; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -9,11 +12,14 @@ import org.osgi.framework.Bundle; import org.osgi.framework.Version; import org.osgi.framework.VersionRange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableSet; import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationType; import com.enonic.xp.context.Context; import com.enonic.xp.context.ContextAccessor; import com.enonic.xp.context.ContextBuilder; @@ -26,11 +32,70 @@ public final class ApplicationHelper { + private static final Logger LOG = LoggerFactory.getLogger( ApplicationHelper.class ); + + private static final String LOCAL_BUNDLE_LOCATION_PREFIX = "local:"; + public static ApplicationKey getApplicationKey( final Bundle bundle ) { return ApplicationKey.from( ApplicationBundleUtils.getApplicationName( bundle ) ); } + /** + * Resolves the application type declared in the application descriptor (enonic.yaml) of the bundle. + * Falls back to {@link ApplicationType#BUNDLE} when the bundle has no descriptor or the descriptor cannot be parsed. + */ + static ApplicationType getApplicationType( final Bundle bundle ) + { + final URL descriptorUrl = ApplicationBundleUtils.DESCRIPTOR_PATHS.stream() + .filter( path -> bundle.getEntry( path ) != null ) + .findFirst() + .map( bundle::getResource ) + .orElse( null ); + + if ( descriptorUrl == null ) + { + return ApplicationType.BUNDLE; + } + + try (InputStream stream = descriptorUrl.openStream()) + { + final String yaml = new String( stream.readAllBytes(), StandardCharsets.UTF_8 ); + return YmlApplicationDescriptorParser.parse( yaml, getApplicationKey( bundle ) ).build().getType(); + } + catch ( Exception e ) + { + LOG.warn( "Unable to resolve application type of [{}], assuming {}", bundle.getSymbolicName(), ApplicationType.BUNDLE, e ); + return ApplicationType.BUNDLE; + } + } + + /** + * {@code true} when the bundle ships a cms descriptor ({@code cms/cms.yaml}), i.e. the application owns its schema. + */ + static boolean hasCmsDescriptor( final Bundle bundle ) + { + return SchemaResourcePaths.CMS_DESCRIPTOR_PATHS.stream().anyMatch( path -> bundle.getEntry( path ) != null ); + } + + /** + * Location of an application bundle. Local applications are marked by a location prefix, + * the only channel that reaches the bundle tracker creating the application. + */ + static String toBundleLocation( final ApplicationKey applicationKey, final boolean local ) + { + return ( local ? LOCAL_BUNDLE_LOCATION_PREFIX : "" ) + applicationKey.getName(); + } + + /** + * {@code true} when the bundle was installed as a local application, see {@link #toBundleLocation(ApplicationKey, boolean)}. + */ + static boolean isLocalApplication( final Bundle bundle ) + { + final String location = bundle.getLocation(); + return location != null && location.startsWith( LOCAL_BUNDLE_LOCATION_PREFIX ); + } + static String getAttribute( final Manifest manifest, final String name, final String defValue ) { if ( manifest == null ) @@ -105,7 +170,7 @@ public static void runAsAdmin( final Runnable runnable ) createAdminContext().runWith( runnable ); } - private static Context createAdminContext() + static Context createAdminContext() { return ContextBuilder.create() .branch( SystemConstants.BRANCH_SYSTEM ) diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistry.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistry.java index d46a765a139..b1e08b6f63c 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistry.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistry.java @@ -16,7 +16,7 @@ public interface ApplicationRegistry List getAll(); - Application install( ApplicationKey applicationKey, ByteSource byteSource ); + Application install( ApplicationKey applicationKey, ByteSource byteSource, boolean local ); void uninstall( ApplicationKey applicationKey ); diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistryImpl.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistryImpl.java index f7c824c1119..35c6ecf134f 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistryImpl.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRegistryImpl.java @@ -72,7 +72,7 @@ public List getAll() } @Override - public Application install( final ApplicationKey applicationKey, final ByteSource byteSource ) + public Application install( final ApplicationKey applicationKey, final ByteSource byteSource, final boolean local ) { final RuntimeException[] exceptionHolder = new RuntimeException[1]; final ApplicationAdaptor app = applications.compute( applicationKey, ( key, existingApp ) -> { @@ -82,9 +82,10 @@ public Application install( final ApplicationKey applicationKey, final ByteSourc } try (InputStream in = byteSource.openStream()) { - LOG.debug( "Installing application {} bundle", key ); + LOG.debug( "Installing {} application {} bundle", local ? "local" : "", key ); - final Bundle bundle = context.installBundle( key.getName(), in ); + // the bundle location carries the local marker: it is the only thing the bundle tracker sees when it creates the application + final Bundle bundle = context.installBundle( ApplicationHelper.toBundleLocation( key, local ), in ); LOG.info( "Installed application {} bundle {}", key, bundle.getBundleId() ); diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoService.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoService.java index 5b7253fd22a..f3ac6b22150 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoService.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoService.java @@ -1,8 +1,9 @@ package com.enonic.xp.core.impl.app; +import java.util.Map; + import com.google.common.io.ByteSource; -import com.enonic.xp.app.Application; import com.enonic.xp.app.ApplicationKey; import com.enonic.xp.node.Node; import com.enonic.xp.node.NodeId; @@ -14,6 +15,15 @@ public interface ApplicationRepoService void deleteApplicationNode( ApplicationKey application ); + /** + * Stores schema resources of the application as nodes below the application node ({@code /applications//cms}). + * An existing {@code cms} subtree is replaced. + * + * @param applicationKey application key + * @param resources schema resources, paths relative to the {@code cms} root mapped to resource content + */ + void persistApplicationSchema( ApplicationKey applicationKey, Map resources ); + Node getApplicationNode( ApplicationKey applicationKey ); ByteSource getApplicationSource( NodeId nodeId ); diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImpl.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImpl.java index f1312973ab5..396b9954630 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImpl.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImpl.java @@ -1,10 +1,18 @@ package com.enonic.xp.core.impl.app; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + import com.google.common.io.ByteSource; import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.data.PropertyTree; +import com.enonic.xp.node.CreateNodeParams; import com.enonic.xp.node.DeleteNodeParams; import com.enonic.xp.node.ListNodesParams; +import com.enonic.xp.node.MoveNodeParams; import com.enonic.xp.node.Node; import com.enonic.xp.node.NodeId; import com.enonic.xp.node.NodeIds; @@ -16,6 +24,7 @@ import com.enonic.xp.node.Nodes; import com.enonic.xp.node.RefreshMode; import com.enonic.xp.node.UpdateNodeParams; +import com.enonic.xp.schema.SchemaNodePropertyNames; import com.enonic.xp.util.BinaryReference; public class ApplicationRepoServiceImpl @@ -23,6 +32,9 @@ public class ApplicationRepoServiceImpl { static final NodePath APPLICATION_PATH = new NodePath( NodePath.ROOT, NodeName.from( "applications" ) ); + // the new schema is staged here before replacing the cms node; the resolvers never address this name + static final String CMS_STAGING_NAME = "cms_staging"; + private final NodeService nodeService; public ApplicationRepoServiceImpl( final NodeService nodeService ) @@ -52,6 +64,122 @@ public void deleteApplicationNode( final ApplicationKey applicationKey ) .build() ); } + /** + * The new schema is built in full under a staging node invisible to the resolvers (they address {@code cms} only), and only then + * swapped in: a failure while building leaves the previously persisted schema untouched and served. The swap itself is delete+rename; + * a crash in between leaves no {@code cms} node, with the complete new schema still under staging — reinstalling repairs it. + */ + @Override + public void persistApplicationSchema( final ApplicationKey applicationKey, final Map resources ) + { + ApplicationHelper.runAsAdmin( () -> { + final NodePath appPath = new NodePath( APPLICATION_PATH, NodeName.from( applicationKey.getName() ) ); + final NodePath cmsPath = new NodePath( appPath, NodeName.from( VirtualAppConstants.CMS_ROOT_NAME ) ); + final NodePath stagingPath = new NodePath( appPath, NodeName.from( CMS_STAGING_NAME ) ); + + // leftover of an earlier failed install + if ( this.nodeService.nodeExists( stagingPath ) ) + { + this.nodeService.delete( DeleteNodeParams.create().nodePath( stagingPath ).refresh( RefreshMode.ALL ).build() ); + } + + final Node stagingNode = this.nodeService.create( CreateNodeParams.create() + .parent( appPath ) + .name( CMS_STAGING_NAME ) + .inheritPermissions( true ) + .refresh( RefreshMode.ALL ) + .build() ); + + try + { + resources.forEach( ( path, content ) -> createResourceNode( stagingPath, path, content ) ); + } + catch ( RuntimeException e ) + { + try + { + this.nodeService.delete( DeleteNodeParams.create().nodePath( stagingPath ).refresh( RefreshMode.ALL ).build() ); + } + catch ( Exception cleanupFailure ) + { + e.addSuppressed( cleanupFailure ); + } + throw e; + } + + if ( this.nodeService.nodeExists( cmsPath ) ) + { + this.nodeService.delete( DeleteNodeParams.create().nodePath( cmsPath ).refresh( RefreshMode.ALL ).build() ); + } + + this.nodeService.move( MoveNodeParams.create() + .nodeId( stagingNode.id() ) + .newName( NodeName.from( VirtualAppConstants.CMS_ROOT_NAME ) ) + .refresh( RefreshMode.ALL ) + .build() ); + + this.nodeService.refresh( RefreshMode.ALL ); + } ); + } + + private void createResourceNode( final NodePath cmsPath, final String resourcePath, final ByteSource content ) + { + final String[] elements = resourcePath.split( "/" ); + + NodePath parent = cmsPath; + for ( int i = 0; i < elements.length - 1; i++ ) + { + final NodePath folderPath = new NodePath( parent, NodeName.from( elements[i] ) ); + if ( !this.nodeService.nodeExists( folderPath ) ) + { + this.nodeService.create( CreateNodeParams.create() + .name( elements[i] ) + .parent( parent ) + .inheritPermissions( true ) + .refresh( RefreshMode.ALL ) + .build() ); + } + parent = folderPath; + } + + final String name = elements[elements.length - 1]; + final String iconMimeType = SchemaResourcePaths.iconMimeType( name ); + + final CreateNodeParams.Builder params = CreateNodeParams.create() + .name( name ) + .parent( parent ) + .inheritPermissions( true ) + .refresh( RefreshMode.ALL ); + + final PropertyTree data = new PropertyTree(); + + if ( iconMimeType != null ) + { + // icons are stored as node binaries, the descriptors and phrases as a text property + data.setString( SchemaNodePropertyNames.MIME_TYPE, iconMimeType ); + data.setBinaryReference( SchemaNodePropertyNames.ICON, VirtualAppConstants.ICON_BINARY_REFERENCE ); + params.attachBinary( VirtualAppConstants.ICON_BINARY_REFERENCE, content ); + } + else + { + data.setString( SchemaNodePropertyNames.RESOURCE, readString( content ) ); + } + + this.nodeService.create( params.data( data ).build() ); + } + + private static String readString( final ByteSource content ) + { + try + { + return content.asCharSource( StandardCharsets.UTF_8 ).read(); + } + catch ( IOException e ) + { + throw new UncheckedIOException( e ); + } + } + @Override public ByteSource getApplicationSource( final NodeId nodeId ) { diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationServiceImpl.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationServiceImpl.java index b764561ab1f..a817cd7374c 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationServiceImpl.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/ApplicationServiceImpl.java @@ -1,6 +1,7 @@ package com.enonic.xp.core.impl.app; import java.util.Collections; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -22,6 +23,7 @@ import com.enonic.xp.app.ApplicationMode; import com.enonic.xp.app.ApplicationNotFoundException; import com.enonic.xp.app.ApplicationService; +import com.enonic.xp.app.ApplicationType; import com.enonic.xp.app.Applications; import com.enonic.xp.app.CreateVirtualApplicationParams; import com.enonic.xp.context.ContextAccessor; @@ -282,11 +284,27 @@ private Application doInstallGlobalApplication( final ByteSource byteSource ) throw new ApplicationBundleException( String.format( "Application %s is not permitted on this instance", applicationKey ) ); } + final Map schemaResources; + try + { + schemaResources = + appInfo.type == ApplicationType.STATIC || appInfo.hasCmsDescriptor ? AppSchemaResolver.resolve( byteSource ) : null; + } + catch ( Exception e ) + { + throw new ApplicationBundleException( "Cannot install application", e ); + } + repoService.upsertApplicationNode( appInfo, byteSource ); this.eventPublisher.publish( ApplicationClusterEvents.install( applicationKey ) ); - final Application application = doInstallApplication( byteSource, applicationKey ); + final Application application = doInstallApplication( byteSource, applicationKey, false ); + + if ( schemaResources != null ) + { + repoService.persistApplicationSchema( applicationKey, schemaResources ); + } LOG.info( "Global Application [{}] installed successfully", applicationKey ); @@ -335,7 +353,7 @@ private void doInstallStoredApplication( final ApplicationKey applicationKey ) "Cannot install application [" + applicationKey + "], system app must not be stored" ); } - doInstallApplication( byteSource, applicationKey ); + doInstallApplication( byteSource, applicationKey, false ); LOG.info( "Stored application [{}] installed successfully", applicationKey ); } @@ -381,7 +399,7 @@ private Application doInstallLocalApplication( final ByteSource byteSource ) { final ApplicationKey applicationKey = ApplicationKey.from( getAppInfo( byteSource ).name ); - final Application application = doInstallApplication( byteSource, applicationKey ); + final Application application = doInstallApplication( byteSource, applicationKey, true ); localApplicationSet.add( applicationKey ); LOG.info( "Local application [{}] installed successfully", applicationKey ); @@ -401,9 +419,9 @@ private void doInstallAndStartStoredApplication( final Node applicationNode ) } } - private Application doInstallApplication( final ByteSource byteSource, final ApplicationKey applicationKey ) + private Application doInstallApplication( final ByteSource byteSource, final ApplicationKey applicationKey, final boolean local ) { - final Application application = this.registry.install( applicationKey, byteSource ); + final Application application = this.registry.install( applicationKey, byteSource, local ); this.eventPublisher.publish( ApplicationEvents.installed( applicationKey ) ); return application; } diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/SchemaResourcePaths.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/SchemaResourcePaths.java new file mode 100644 index 00000000000..5012dc4fad0 --- /dev/null +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/SchemaResourcePaths.java @@ -0,0 +1,93 @@ +package com.enonic.xp.core.impl.app; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * Defines which application resources are "schema resources": descriptors, schema icons and i18n phrases located under {@code cms/}. + * These are the resources persisted as nodes for applications that own their schema ({@code type: Static} or shipping {@code cms/cms.yaml}). + */ +public final class SchemaResourcePaths +{ + /** + * Paths of the cms descriptor inside an application jar/bundle. An application shipping one of these owns its schema. + */ + public static final List CMS_DESCRIPTOR_PATHS = + List.of( VirtualAppConstants.CMS_ROOT_NAME + "/" + VirtualAppConstants.CMS_ROOT_NAME + ".yaml", + VirtualAppConstants.CMS_ROOT_NAME + "/" + VirtualAppConstants.CMS_ROOT_NAME + ".yml" ); + + public static final String MACROS_ROOT_NAME = "macros"; + + public static final String I18N_ROOT_NAME = "i18n"; + + public static final String PHRASES_ROOT_NAME = "phrases"; + + public static final String SVG_EXTENSION = "svg"; + + public static final String PNG_EXTENSION = "png"; + + public static final String SVG_MIME_TYPE = "image/svg+xml"; + + public static final String PNG_MIME_TYPE = "image/png"; + + // descriptor path relative to the cms root, without extension + public static final String DESCRIPTOR_PATH_GROUP = "descriptorPath"; + + // schema name (folder and file name of a descriptor) + public static final String SCHEMA_NAME_GROUP = "schemaName"; + + // descriptor extension: yaml or yml + public static final String EXTENSION_GROUP = "extension"; + + // schema icon path relative to the cms root, with extension + public static final String ICON_PATH_GROUP = "iconPath"; + + // phrases .properties path relative to the cms root, with extension + public static final String PHRASES_PATH_GROUP = "phrasesPath"; + + private static final String SCHEMA_NAME_2_GROUP = "iconName"; + + private static final String DESCRIPTOR_ROOTS = + String.join( "|", VirtualAppConstants.CONTENT_TYPE_ROOT_NAME, VirtualAppConstants.FORM_FRAGMENTS_ROOT_NAME, + VirtualAppConstants.MIXINS_ROOT_NAME, VirtualAppConstants.PART_ROOT_NAME, VirtualAppConstants.LAYOUT_ROOT_NAME, + VirtualAppConstants.PAGE_ROOT_NAME, MACROS_ROOT_NAME ); + + // icons exist for content types, form fragments, mixins, parts and macros + private static final String ICON_ROOTS = + String.join( "|", VirtualAppConstants.CONTENT_TYPE_ROOT_NAME, VirtualAppConstants.FORM_FRAGMENTS_ROOT_NAME, + VirtualAppConstants.MIXINS_ROOT_NAME, VirtualAppConstants.PART_ROOT_NAME, MACROS_ROOT_NAME ); + + public static final Pattern SCHEMA_RESOURCE_PATTERN = Pattern.compile( + "^" + VirtualAppConstants.CMS_ROOT_NAME + "/(?:(?<" + DESCRIPTOR_PATH_GROUP + ">(?:" + DESCRIPTOR_ROOTS + ")/(?<" + + SCHEMA_NAME_GROUP + ">[^/]+)/\\k<" + SCHEMA_NAME_GROUP + ">|" + VirtualAppConstants.CMS_ROOT_NAME + "|" + + VirtualAppConstants.STYLE_ROOT_NAME + "/" + VirtualAppConstants.STYLE_NAME + ")\\.(?<" + EXTENSION_GROUP + ">yaml|yml)|(?<" + + ICON_PATH_GROUP + ">(?:" + ICON_ROOTS + ")/(?<" + SCHEMA_NAME_2_GROUP + ">[^/]+)/\\k<" + SCHEMA_NAME_2_GROUP + ">\\.(?:" + + SVG_EXTENSION + "|" + PNG_EXTENSION + "))|(?<" + PHRASES_PATH_GROUP + ">" + I18N_ROOT_NAME + "/" + PHRASES_ROOT_NAME + + "/[^/]+\\.properties))$" ); + + private SchemaResourcePaths() + { + } + + public static boolean isSchemaResourcePath( final String path ) + { + final String normalized = path.startsWith( "/" ) ? path.substring( 1 ) : path; + return SCHEMA_RESOURCE_PATTERN.matcher( normalized ).matches(); + } + + /** + * Mime type of a schema icon, or {@code null} if the path is not an icon. + */ + public static String iconMimeType( final String path ) + { + if ( path.endsWith( "." + SVG_EXTENSION ) ) + { + return SVG_MIME_TYPE; + } + else if ( path.endsWith( "." + PNG_EXTENSION ) ) + { + return PNG_MIME_TYPE; + } + return null; + } +} \ No newline at end of file diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppConstants.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppConstants.java index dee8676cd33..2cde5bf11da 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppConstants.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppConstants.java @@ -11,6 +11,7 @@ import com.enonic.xp.security.acl.AccessControlEntry; import com.enonic.xp.security.acl.AccessControlList; import com.enonic.xp.security.acl.Permission; +import com.enonic.xp.util.BinaryReference; public final class VirtualAppConstants { @@ -42,6 +43,8 @@ public final class VirtualAppConstants public static final String STYLE_NAME = "style"; + public static final BinaryReference ICON_BINARY_REFERENCE = BinaryReference.from( "icon" ); + public static final NodePath VIRTUAL_APP_ROOT_PARENT = NodePath.ROOT; public static final Branch VIRTUAL_APP_BRANCH = Branch.from( "master" ); diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppFactory.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppFactory.java index bdf78e1c684..78e65295bc3 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppFactory.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/VirtualAppFactory.java @@ -28,7 +28,7 @@ public static ApplicationAdaptor create( final ApplicationKey applicationKey, fi @Override public ApplicationUrlResolver getUrlResolver() { - return new MultiApplicationUrlResolver( new NodeResourceApplicationUrlResolver( applicationKey, nodeService ), + return new MultiApplicationUrlResolver( NodeResourceApplicationUrlResolver.forVirtualApp( applicationKey, nodeService ), new FakeCmsYamlUrlResolver( applicationKey, nodeService ) ); } diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParser.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParser.java index f2974347b8d..fb11a06a0e7 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParser.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParser.java @@ -1,11 +1,18 @@ package com.enonic.xp.core.impl.app; +import java.io.IOException; + import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.enonic.xp.app.ApplicationDescriptor; import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationType; import com.enonic.xp.core.impl.schema.YmlParserBase; import com.enonic.xp.schema.LocalizedText; import com.enonic.xp.util.GenericValue; @@ -27,6 +34,10 @@ static ApplicationDescriptor.Builder parse( final String resource, final Applica @JsonIgnoreProperties("kind") private abstract static class ApplicationDescriptorBuilderMapper { + @JsonProperty("type") + @JsonDeserialize(using = ApplicationTypeDeserializer.class) + abstract ApplicationDescriptor.Builder type( ApplicationType type ); + @JsonProperty("title") abstract ApplicationDescriptor.Builder title( LocalizedText text ); @@ -48,4 +59,21 @@ private abstract static class ApplicationDescriptorBuilderMapper @JsonProperty("config") abstract ApplicationDescriptor.Builder schemaConfig( GenericValue schemaConfig ); } + + private static class ApplicationTypeDeserializer + extends JsonDeserializer + { + @Override + public ApplicationType deserialize( final JsonParser parser, final DeserializationContext context ) + throws IOException + { + final String value = parser.getValueAsString(); + return switch ( value ) + { + case "Static" -> ApplicationType.STATIC; + case "Bundle" -> ApplicationType.BUNDLE; + default -> throw new IllegalArgumentException( String.format( "Unknown application type \"%s\"", value ) ); + }; + } + } } diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolver.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolver.java new file mode 100644 index 00000000000..a876fe64a9e --- /dev/null +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolver.java @@ -0,0 +1,36 @@ +package com.enonic.xp.core.impl.app.resolver; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import com.enonic.xp.resource.Resource; + +public final class FilteredApplicationUrlResolver + implements ApplicationUrlResolver +{ + private final ApplicationUrlResolver delegate; + + private final Supplier> includeSupplier; + + public FilteredApplicationUrlResolver( final ApplicationUrlResolver delegate, final Supplier> includeSupplier ) + { + this.delegate = delegate; + this.includeSupplier = includeSupplier; + } + + @Override + public Set findFiles() + { + final Predicate include = includeSupplier.get(); + return delegate.findFiles().stream().filter( include ).collect( Collectors.toCollection( LinkedHashSet::new ) ); + } + + @Override + public Resource findResource( final String path ) + { + return includeSupplier.get().test( path ) ? delegate.findResource( path ) : null; + } +} diff --git a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolver.java b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolver.java index d315ea600ce..d2ca44919a1 100644 --- a/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolver.java +++ b/modules/core/core-app/src/main/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolver.java @@ -3,9 +3,13 @@ import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; +import com.google.common.io.ByteSource; + import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.context.Context; import com.enonic.xp.core.impl.app.NodeValueResource; import com.enonic.xp.core.impl.app.VirtualAppConstants; import com.enonic.xp.core.impl.app.VirtualAppContext; @@ -18,6 +22,10 @@ import com.enonic.xp.resource.Resource; import com.enonic.xp.resource.ResourceKey; +/** + * Serves application resources stored as nodes below {@code /cms}. + * Resource paths are relative to the application node, e.g. {@code /cms/content-types/mytype/mytype.yaml}. + */ public final class NodeResourceApplicationUrlResolver implements ApplicationUrlResolver { @@ -25,32 +33,50 @@ public final class NodeResourceApplicationUrlResolver private final NodeService nodeService; - public NodeResourceApplicationUrlResolver( final ApplicationKey applicationKey, final NodeService nodeService ) + private final NodePath appNodePath; + + private final Supplier contextSupplier; + + public NodeResourceApplicationUrlResolver( final ApplicationKey applicationKey, final NodeService nodeService, final NodePath appNodePath, + final Supplier contextSupplier ) { this.applicationKey = applicationKey; this.nodeService = nodeService; + this.appNodePath = appNodePath; + this.contextSupplier = contextSupplier; + } + + /** + * Resolver for a virtual application stored in the {@code system.app} repository. + */ + public static NodeResourceApplicationUrlResolver forVirtualApp( final ApplicationKey applicationKey, final NodeService nodeService ) + { + return new NodeResourceApplicationUrlResolver( applicationKey, nodeService, new NodePath( VirtualAppConstants.VIRTUAL_APP_ROOT_PARENT, + NodeName.from( applicationKey.toString() ) ), + VirtualAppContext::createContext ); } @Override public Set findFiles() { - final NodePath cmsPath = NodePath.create( VirtualAppConstants.VIRTUAL_APP_ROOT_PARENT ) - .addElement( applicationKey.toString() ) - .addElement( VirtualAppConstants.CMS_ROOT_NAME ) - .build(); + final NodePath cmsPath = new NodePath( appNodePath, NodeName.from( VirtualAppConstants.CMS_ROOT_NAME ) ); + final int appPathLength = appNodePath.toString().length(); - return VirtualAppContext.createContext().callWith( () -> { + return contextSupplier.get().callWith( () -> { return this.nodeService.list( ListNodesParams.create().parentPath( cmsPath ).build() ) .map( NodeListEntry::nodePath ) - .filter( nodePath -> isResource( cmsPath, nodePath ) ) - .map( nodePath -> nodePath.toString().substring( nodePath.toString().indexOf( '/', 1 ) ) ) + .filter( NodeResourceApplicationUrlResolver::isResource ) + .map( nodePath -> nodePath.toString().substring( appPathLength ) ) .collect( Collectors.toCollection( LinkedHashSet::new ) ); } ); } - private static boolean isResource( final NodePath cmsPath, final NodePath nodePath ) + /** + * A resource is a file node (its name has an extension); nodes without an extension are folders on the way to a resource. + */ + private static boolean isResource( final NodePath nodePath ) { - return cmsPath.equals( nodePath.getParentPath().getParentPath().getParentPath() ); + return nodePath.getName().toString().contains( "." ); } @Override @@ -61,21 +87,27 @@ public Resource findResource( final String path ) return null; } - final NodePath appPath = new NodePath( VirtualAppConstants.VIRTUAL_APP_ROOT_PARENT, NodeName.from( applicationKey.toString() ) ); - - final NodePath.Builder builder = NodePath.create( appPath ); + final NodePath.Builder builder = NodePath.create( appNodePath ); Arrays.stream( path.split( "/" ) ).forEach( builder::addElement ); - final Node resourceNode = VirtualAppContext.createContext().callWith( () -> nodeService.getByPath( builder.build() ) ); + return contextSupplier.get().callWith( () -> { + final Node resourceNode = nodeService.getByPath( builder.build() ); - if ( resourceNode == null ) - { - return null; - } - else - { - return new NodeValueResource( ResourceKey.from( applicationKey, path ), resourceNode ); - } + if ( resourceNode == null ) + { + return null; + } + + final ResourceKey resourceKey = ResourceKey.from( applicationKey, path ); + + if ( resourceNode.getAttachedBinaries().getByBinaryReference( VirtualAppConstants.ICON_BINARY_REFERENCE ) != null ) + { + final ByteSource binary = nodeService.getBinary( resourceNode.id(), VirtualAppConstants.ICON_BINARY_REFERENCE ); + return new NodeValueResource( resourceKey, binary, resourceNode.getTimestamp() ); + } + + return new NodeValueResource( resourceKey, resourceNode ); + } ); } -} +} \ No newline at end of file diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppInfoResolverTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppInfoResolverTest.java index 8ecc6346d7a..4423e23bcf3 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppInfoResolverTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppInfoResolverTest.java @@ -12,8 +12,12 @@ import com.google.common.io.ByteSource; import com.google.common.io.ByteStreams; +import com.enonic.xp.app.ApplicationType; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class AppInfoResolverTest extends BundleBasedTest @@ -95,6 +99,64 @@ void descriptor_application_yml() assertEquals( "Application title", appInfo.title ); } + @Test + void descriptor_type_static() + throws Exception + { + final ByteSource source = wrapBundle( newBundle( "myBundle", true ).addResource( "enonic.yaml", ByteSource.wrap( + "kind: \"Application\"\ntype: \"Static\"\n".getBytes( StandardCharsets.UTF_8 ) ).openStream() ) ); + + assertEquals( ApplicationType.STATIC, AppInfoResolver.resolve( source ).type ); + } + + @Test + void descriptor_type_defaults_to_bundle() + throws Exception + { + final ByteSource withDescriptor = wrapBundle( newBundle( "myBundle", true ).addResource( "enonic.yaml", descriptorYaml( "title" ) ) ); + assertEquals( ApplicationType.BUNDLE, AppInfoResolver.resolve( withDescriptor ).type ); + + final ByteSource withoutDescriptor = wrapBundle( newBundle( "myBundle", true ) ); + assertEquals( ApplicationType.BUNDLE, AppInfoResolver.resolve( withoutDescriptor ).type ); + } + + @Test + void has_cms_descriptor_yaml() + throws Exception + { + final ByteSource source = wrapBundle( newBundle( "myBundle", true ).addResource( "cms/cms.yaml", content( "kind: \"CMS\"" ) ) ); + + assertTrue( AppInfoResolver.resolve( source ).hasCmsDescriptor ); + } + + @Test + void has_cms_descriptor_yml() + throws Exception + { + final ByteSource source = wrapBundle( newBundle( "myBundle", true ).addResource( "cms/cms.yml", content( "kind: \"CMS\"" ) ) ); + + assertTrue( AppInfoResolver.resolve( source ).hasCmsDescriptor ); + } + + @Test + void has_cms_descriptor_missing() + throws Exception + { + final ByteSource withoutCms = wrapBundle( newBundle( "myBundle", true ).addResource( "enonic.yaml", descriptorYaml( "title" ) ) + .addResource( "cms/content-types/mytype/mytype.yaml", content( "kind: \"ContentType\"" ) ) ); + assertFalse( AppInfoResolver.resolve( withoutCms ).hasCmsDescriptor ); + + // cms.yaml is only recognized below the cms root + final ByteSource rootCms = wrapBundle( newBundle( "myBundle", true ).addResource( "cms.yaml", content( "kind: \"CMS\"" ) ) ); + assertFalse( AppInfoResolver.resolve( rootCms ).hasCmsDescriptor ); + } + + private static InputStream content( final String value ) + throws IOException + { + return ByteSource.wrap( value.getBytes( StandardCharsets.UTF_8 ) ).openStream(); + } + private static InputStream descriptorYaml( final String title ) throws IOException { diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppSchemaResolverTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppSchemaResolverTest.java new file mode 100644 index 00000000000..f07f1942ccc --- /dev/null +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/AppSchemaResolverTest.java @@ -0,0 +1,98 @@ +package com.enonic.xp.core.impl.app; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +import com.google.common.io.ByteSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AppSchemaResolverTest +{ + @Test + void resolve() + throws Exception + { + final ByteSource byteSource = zip( new String[][]{{"cms/cms.yml", "cms-descriptor"}, {"cms/style/style.yaml", "styles"}, + {"cms/content-types/mytype/mytype.yaml", "content-type-yaml"}, {"cms/content-types/mytype/mytype.yml", "content-type-yml"}, + {"cms/layouts/mylayout/mylayout.yml", "layout-yml"}, {"cms/layouts/mylayout/mylayout.yaml", "layout-yaml"}, + {"cms/macros/mymacro/mymacro.yml", "macro-yml"}, {"cms/parts/mypart/mypart.yaml", "part"}, + {"cms/pages/mypage/mypage.yaml", "page"}, {"cms/form-fragments/myfragment/myfragment.yaml", "fragment"}, + {"cms/mixins/mymixin/mymixin.yaml", "mixin"}, {"cms/content-types/other/wrong.yaml", "ignored"}, + {"cms/macros/mymacro/mymacro.js", "ignored"}, {"cms/content-types/mytype/mytype.svg", "type-icon"}, + {"cms/parts/mypart/mypart.png", "part-icon"}, {"cms/content-types/mytype/other.svg", "ignored"}, + {"cms/pages/mypage/mypage.svg", "ignored"}, {"assets/styles.yaml", "ignored"}, + {"cms/unknown/mything/mything.yaml", "ignored"}, {"cms/i18n/phrases/phrases.properties", "phrases-default"}, + {"cms/i18n/phrases/phrases_en.properties", "phrases-en"}, {"i18n/phrases/phrases.properties", "ignored"}, + {"i18n/phrases.properties", "ignored"}, {"cms/i18n/loose.properties", "ignored"}, + {"cms/i18n/phrases/nested/deep.properties", "ignored"}, {"cms/i18n/phrases/phrases.yaml", "ignored"}} ); + + final Map resources = AppSchemaResolver.resolve( byteSource ); + + assertEquals( 13, resources.size() ); + assertEquals( "cms-descriptor", read( resources, "cms.yaml" ) ); + assertEquals( "styles", read( resources, "style/style.yaml" ) ); + assertEquals( "content-type-yaml", read( resources, "content-types/mytype/mytype.yaml" ) ); + assertEquals( "layout-yaml", read( resources, "layouts/mylayout/mylayout.yaml" ) ); + assertEquals( "macro-yml", read( resources, "macros/mymacro/mymacro.yaml" ) ); + assertEquals( "part", read( resources, "parts/mypart/mypart.yaml" ) ); + assertEquals( "page", read( resources, "pages/mypage/mypage.yaml" ) ); + assertEquals( "fragment", read( resources, "form-fragments/myfragment/myfragment.yaml" ) ); + assertEquals( "mixin", read( resources, "mixins/mymixin/mymixin.yaml" ) ); + assertEquals( "phrases-default", read( resources, "i18n/phrases/phrases.properties" ) ); + assertEquals( "phrases-en", read( resources, "i18n/phrases/phrases_en.properties" ) ); + assertEquals( "type-icon", read( resources, "content-types/mytype/mytype.svg" ) ); + assertEquals( "part-icon", read( resources, "parts/mypart/mypart.png" ) ); + } + + private static String read( final Map resources, final String path ) + throws Exception + { + return resources.get( path ).asCharSource( StandardCharsets.UTF_8 ).read(); + } + + @Test + void resolve_no_schema_resources() + throws Exception + { + final ByteSource byteSource = zip( new String[][]{{"enonic.yaml", "kind: \"Application\""}, {"assets/app.js", "js"}} ); + + assertTrue( AppSchemaResolver.resolve( byteSource ).isEmpty() ); + } + + @Test + void resolve_normalizes_yml_to_yaml_only_for_descriptors() + throws Exception + { + final ByteSource byteSource = + zip( new String[][]{{"cms/parts/mypart/mypart.yml", "part"}, {"cms/parts/mypart/mypart.png", "icon"}} ); + + final Map resources = AppSchemaResolver.resolve( byteSource ); + + assertEquals( 2, resources.size() ); + assertEquals( "part", read( resources, "parts/mypart/mypart.yaml" ) ); + assertEquals( "icon", read( resources, "parts/mypart/mypart.png" ) ); + } + + private static ByteSource zip( final String[][] entries ) + throws Exception + { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream( out )) + { + for ( final String[] entry : entries ) + { + zip.putNextEntry( new ZipEntry( entry[0] ) ); + zip.write( entry[1].getBytes( StandardCharsets.UTF_8 ) ); + zip.closeEntry(); + } + } + return ByteSource.wrap( out.toByteArray() ); + } +} diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationFactoryTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationFactoryTest.java index 203443e08b9..0011f7cc002 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationFactoryTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationFactoryTest.java @@ -1,5 +1,11 @@ package com.enonic.xp.core.impl.app; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.stream.Stream; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -11,14 +17,18 @@ import com.enonic.xp.core.impl.app.resolver.BundleApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.MultiApplicationUrlResolver; import com.enonic.xp.core.impl.app.resolver.NodeResourceApplicationUrlResolver; +import com.enonic.xp.node.NodePath; import com.enonic.xp.node.NodeService; import com.enonic.xp.server.RunMode; import com.enonic.xp.server.RunModeSupport; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -162,6 +172,188 @@ void createUrlResolverByName() assertThrows( IllegalArgumentException.class, () -> applicationFactory.createUrlResolver( bundle, "unknown" ) ); } + @Test + void static_app_resolver_is_multi_regardless_of_virtual_flags() + { + final Bundle bundle = deploy( "app1", createStaticBundle( "app1" ) ); + + final AppConfig appConfig = mock( AppConfig.class ); + when( appConfig.virtual_enabled() ).thenReturn( false ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + assertInstanceOf( MultiApplicationUrlResolver.class, resolver ); + } + + @Test + void static_schema_from_bundle_when_cms_node_missing() + { + final Bundle bundle = deploy( "app1", createStaticBundle( "app1" ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( any( NodePath.class ) ) ).thenReturn( false ); + when( nodeService.list( any() ) ).thenAnswer( invocation -> Stream.empty() ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertNotNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + assertNotNull( resolver.findResource( "/" + ICON_PATH ) ); + assertNotNull( resolver.findResource( "/" + PHRASES_PATH ) ); + + final Set files = resolver.findFiles(); + assertTrue( files.contains( CONTENT_TYPE_PATH ) ); + assertTrue( files.contains( ICON_PATH ) ); + assertTrue( files.contains( PHRASES_PATH ) ); + } + + @Test + void static_schema_not_contributed_by_bundle_when_cms_node_exists() + { + final Bundle bundle = deploy( "app1", createStaticBundle( "app1" ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( new NodePath( "/applications/app1/cms" ) ) ).thenReturn( true ); + when( nodeService.list( any() ) ).thenAnswer( invocation -> Stream.empty() ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + // schema resources, icons included, are served from nodes only + assertNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + assertNull( resolver.findResource( "/" + PHRASES_PATH ) ); + assertNull( resolver.findResource( "/" + ICON_PATH ) ); + + final Set files = resolver.findFiles(); + assertFalse( files.contains( CONTENT_TYPE_PATH ) ); + assertFalse( files.contains( PHRASES_PATH ) ); + assertFalse( files.contains( ICON_PATH ) ); + } + + @Test + void bundle_app_without_schema_uses_bundle_resolver() + { + // no cms/cms.yaml in the bundle and no persisted schema: plain bundle resolver, schema descriptors come from the bundle + final Bundle bundle = deploy( "app1", createBundleWithCmsResources( newBundle( "app1", true ) ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( any( NodePath.class ) ) ).thenReturn( false ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertInstanceOf( BundleApplicationUrlResolver.class, resolver ); + assertNotNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + } + + @Test + void bundle_app_with_persisted_schema_is_node_backed() + { + // no cms/cms.yaml in the bundle, but a schema persisted by an earlier version exists: schema from nodes, logic from the bundle + final Bundle bundle = deploy( "app1", createBundleWithCmsResources( newBundle( "app1", true ) ).addResource( CONTROLLER_PATH, + stream( "controller" ) ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( new NodePath( "/applications/app1/cms" ) ) ).thenReturn( true ); + when( nodeService.list( any() ) ).thenAnswer( invocation -> Stream.empty() ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertInstanceOf( MultiApplicationUrlResolver.class, resolver ); + assertNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + assertNull( resolver.findResource( "/" + ICON_PATH ) ); + assertNull( resolver.findResource( "/" + PHRASES_PATH ) ); + assertNotNull( resolver.findResource( "/" + CONTROLLER_PATH ) ); + + final Set files = resolver.findFiles(); + assertFalse( files.contains( CONTENT_TYPE_PATH ) ); + assertTrue( files.contains( CONTROLLER_PATH ) ); + } + + @Test + void bundle_app_with_cms_descriptor_is_node_backed() + { + // cms/cms.yaml in the bundle: node backed regardless of type; the bundle serves the schema until it is persisted + final Bundle bundle = deploy( "app1", createBundleWithCmsResources( newBundle( "app1", true ) ).addResource( "cms/cms.yaml", stream( + "kind: \"CMS\"" ) ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( any( NodePath.class ) ) ).thenReturn( false ); + when( nodeService.list( any() ) ).thenAnswer( invocation -> Stream.empty() ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertInstanceOf( MultiApplicationUrlResolver.class, resolver ); + assertNotNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + assertNotNull( resolver.findResource( "/cms/cms.yaml" ) ); + } + + @Test + void local_app_with_cms_descriptor_ignores_persisted_schema() + { + // a local application owning its schema is never shadowed by the schema persisted for the global installation + final Bundle bundle = deploy( "local:app1", createBundleWithCmsResources( newBundle( "app1", true ) ).addResource( "cms/cms.yaml", stream( + "kind: \"CMS\"" ) ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( any( NodePath.class ) ) ).thenReturn( true ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertInstanceOf( BundleApplicationUrlResolver.class, resolver ); + assertNotNull( resolver.findResource( "/cms/cms.yaml" ) ); + assertNotNull( resolver.findResource( "/" + CONTENT_TYPE_PATH ) ); + assertTrue( resolver.findFiles().contains( CONTENT_TYPE_PATH ) ); + } + + @Test + void local_app_without_cms_descriptor_uses_persisted_schema() + { + // a local application shipping logic only still runs on the persisted schema + final Bundle bundle = deploy( "local:app1", newBundle( "app1", true ).addResource( CONTROLLER_PATH, stream( "controller" ) ) ); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + when( nodeService.nodeExists( new NodePath( "/applications/app1/cms" ) ) ).thenReturn( true ); + when( nodeService.list( any() ) ).thenAnswer( invocation -> Stream.empty() ); + RunModeSupport.set( RunMode.PROD ); + + final ApplicationUrlResolver resolver = new ApplicationFactory( nodeService, appConfig ).createUrlResolver( bundle, null ); + + assertInstanceOf( MultiApplicationUrlResolver.class, resolver ); + assertNotNull( resolver.findResource( "/" + CONTROLLER_PATH ) ); + } + + private static final String CONTENT_TYPE_PATH = "cms/content-types/mytype/mytype.yaml"; + + private static final String CONTROLLER_PATH = "cms/parts/mypart/mypart.js"; + + private static final String ICON_PATH = "cms/content-types/mytype/mytype.svg"; + + private static final String PHRASES_PATH = "cms/i18n/phrases/phrases_en.properties"; + + private TinyBundle createStaticBundle( final String name ) + { + final TinyBundle bundle = newBundle( name, true ); + bundle.addResource( "enonic.yaml", stream( "kind: \"Application\"\ntype: \"Static\"\n" ) ); + return createBundleWithCmsResources( bundle ); + } + + private TinyBundle createBundleWithCmsResources( final TinyBundle bundle ) + { + bundle.addResource( CONTENT_TYPE_PATH, stream( "kind: \"ContentType\"" ) ); + bundle.addResource( ICON_PATH, stream( "" ) ); + bundle.addResource( PHRASES_PATH, stream( "key=value" ) ); + return bundle; + } + + private static InputStream stream( final String content ) + { + return new ByteArrayInputStream( content.getBytes( StandardCharsets.UTF_8 ) ); + } + private Bundle deploy( final String name, final boolean isApp, final boolean hasSourcePath ) { if ( hasSourcePath ) diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationHelperTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationHelperTest.java new file mode 100644 index 00000000000..33a76279eb0 --- /dev/null +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationHelperTest.java @@ -0,0 +1,119 @@ +package com.enonic.xp.core.impl.app; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.osgi.framework.Bundle; + +import com.google.common.io.ByteSource; + +import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApplicationHelperTest + extends BundleBasedTest +{ + @Test + void getApplicationType_static() + throws Exception + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "enonic.yaml", yaml( "type: \"Static\"" ) ) ); + + assertEquals( ApplicationType.STATIC, ApplicationHelper.getApplicationType( bundle ) ); + } + + @Test + void getApplicationType_bundle() + throws Exception + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "enonic.yaml", yaml( "type: \"Bundle\"" ) ) ); + + assertEquals( ApplicationType.BUNDLE, ApplicationHelper.getApplicationType( bundle ) ); + } + + @Test + void getApplicationType_default() + throws Exception + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "application.yaml", yaml( "title: \"App\"" ) ) ); + + assertEquals( ApplicationType.BUNDLE, ApplicationHelper.getApplicationType( bundle ) ); + } + + @Test + void getApplicationType_no_descriptor() + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ) ); + + assertEquals( ApplicationType.BUNDLE, ApplicationHelper.getApplicationType( bundle ) ); + } + + @Test + void getApplicationType_invalid_descriptor() + throws Exception + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "enonic.yaml", yaml( "type: \"Virtual\"" ) ) ); + + assertEquals( ApplicationType.BUNDLE, ApplicationHelper.getApplicationType( bundle ) ); + } + + @Test + void hasCmsDescriptor_yaml() + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "cms/cms.yaml", stream( "kind: \"CMS\"" ) ) ); + + assertTrue( ApplicationHelper.hasCmsDescriptor( bundle ) ); + } + + @Test + void hasCmsDescriptor_yml() + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "cms/cms.yml", stream( "kind: \"CMS\"" ) ) ); + + assertTrue( ApplicationHelper.hasCmsDescriptor( bundle ) ); + } + + @Test + void hasCmsDescriptor_missing() + { + final Bundle bundle = deploy( "app1", newBundle( "app1", true ).addResource( "cms.yaml", stream( "kind: \"CMS\"" ) ) + .addResource( "cms/content-types/mytype/mytype.yaml", stream( "kind: \"ContentType\"" ) ) ); + + assertFalse( ApplicationHelper.hasCmsDescriptor( bundle ) ); + } + + @Test + void isLocalApplication_by_bundle_location() + { + final ApplicationKey appKey = ApplicationKey.from( "app1" ); + + assertEquals( "app1", ApplicationHelper.toBundleLocation( appKey, false ) ); + assertEquals( "local:app1", ApplicationHelper.toBundleLocation( appKey, true ) ); + + final Bundle global = deploy( ApplicationHelper.toBundleLocation( appKey, false ), newBundle( "app1", true ) ); + assertFalse( ApplicationHelper.isLocalApplication( global ) ); + assertEquals( appKey, ApplicationHelper.getApplicationKey( global ) ); + + final Bundle local = deploy( ApplicationHelper.toBundleLocation( appKey, true ), newBundle( "app1", true, "1.0.1" ) ); + assertTrue( ApplicationHelper.isLocalApplication( local ) ); + assertEquals( appKey, ApplicationHelper.getApplicationKey( local ) ); + } + + private static InputStream stream( final String content ) + { + return new ByteArrayInputStream( content.getBytes( StandardCharsets.UTF_8 ) ); + } + + private static InputStream yaml( final String line ) + throws IOException + { + return ByteSource.wrap( ( "kind: \"Application\"\n" + line + "\n" ).getBytes( StandardCharsets.UTF_8 ) ).openStream(); + } +} diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImplTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImplTest.java index b152e7f6a45..ed35948a69d 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImplTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationRepoServiceImplTest.java @@ -1,10 +1,14 @@ package com.enonic.xp.core.impl.app; -import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mockito; import com.google.common.io.ByteSource; @@ -12,19 +16,23 @@ import com.enonic.xp.app.ApplicationKey; import com.enonic.xp.node.CreateNodeParams; import com.enonic.xp.node.DeleteNodeParams; +import com.enonic.xp.node.MoveNodeParams; import com.enonic.xp.node.Node; import com.enonic.xp.node.NodeId; import com.enonic.xp.node.NodeName; import com.enonic.xp.node.NodePath; import com.enonic.xp.node.NodeService; +import com.enonic.xp.node.RefreshMode; import com.enonic.xp.node.UpdateNodeParams; +import com.enonic.xp.schema.SchemaNodePropertyNames; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class ApplicationRepoServiceImplTest { - private static final String ROOT_TEST_PATH = "src/test/resources"; - private final NodeService nodeService = Mockito.mock( NodeService.class ); private ApplicationRepoServiceImpl service; @@ -77,6 +85,172 @@ void delete_node() argCaptor.getValue().getNodePath() ); } + @Test + void persist_schema_builds_staging_then_swaps() + { + final Map resources = new LinkedHashMap<>(); + resources.put( "cms.yaml", ByteSource.wrap( "cms-descriptor".getBytes( StandardCharsets.UTF_8 ) ) ); + resources.put( "content-types/mytype/mytype.yaml", ByteSource.wrap( "content-type".getBytes( StandardCharsets.UTF_8 ) ) ); + resources.put( "i18n/phrases/phrases_en.properties", ByteSource.wrap( "phrases".getBytes( StandardCharsets.UTF_8 ) ) ); + + final Node stagingNode = stubCreate(); + + this.service.persistApplicationSchema( ApplicationKey.from( "myBundle" ), resources ); + + Mockito.verify( this.nodeService, Mockito.never() ).delete( Mockito.any( DeleteNodeParams.class ) ); + + final ArgumentCaptor captor = ArgumentCaptor.forClass( CreateNodeParams.class ); + Mockito.verify( this.nodeService, Mockito.times( 8 ) ).create( captor.capture() ); + + final List created = captor.getAllValues(); + assertEquals( List.of( "/applications/myBundle/cms_staging", "/applications/myBundle/cms_staging/cms.yaml", + "/applications/myBundle/cms_staging/content-types", "/applications/myBundle/cms_staging/content-types/mytype", + "/applications/myBundle/cms_staging/content-types/mytype/mytype.yaml", + "/applications/myBundle/cms_staging/i18n", "/applications/myBundle/cms_staging/i18n/phrases", + "/applications/myBundle/cms_staging/i18n/phrases/phrases_en.properties" ), + created.stream().map( params -> new NodePath( params.getParent(), params.getName() ).toString() ).toList() ); + + assertEquals( "content-type", created.stream() + .filter( params -> "mytype.yaml".equals( params.getName().toString() ) ) + .findFirst() + .orElseThrow() + .getData() + .getString( SchemaNodePropertyNames.RESOURCE ) ); + assertEquals( "phrases", created.stream() + .filter( params -> "phrases_en.properties".equals( params.getName().toString() ) ) + .findFirst() + .orElseThrow() + .getData() + .getString( SchemaNodePropertyNames.RESOURCE ) ); + + final ArgumentCaptor moveCaptor = ArgumentCaptor.forClass( MoveNodeParams.class ); + Mockito.verify( this.nodeService ).move( moveCaptor.capture() ); + assertEquals( stagingNode.id(), moveCaptor.getValue().getNodeId() ); + assertEquals( VirtualAppConstants.CMS_ROOT_NAME, moveCaptor.getValue().getNewNodeName().toString() ); + + Mockito.verify( this.nodeService ).refresh( RefreshMode.ALL ); + } + + @Test + void persist_schema_stores_icons_as_binaries() + { + final Map resources = + Map.of( "content-types/mytype/mytype.svg", ByteSource.wrap( "".getBytes( StandardCharsets.UTF_8 ) ) ); + + stubCreate(); + + this.service.persistApplicationSchema( ApplicationKey.from( "myBundle" ), resources ); + + final ArgumentCaptor captor = ArgumentCaptor.forClass( CreateNodeParams.class ); + Mockito.verify( this.nodeService, Mockito.atLeastOnce() ).create( captor.capture() ); + + final CreateNodeParams iconParams = captor.getAllValues() + .stream() + .filter( params -> "mytype.svg".equals( params.getName().toString() ) ) + .findFirst() + .orElseThrow(); + + assertEquals( SchemaResourcePaths.SVG_MIME_TYPE, iconParams.getData().getString( SchemaNodePropertyNames.MIME_TYPE ) ); + assertEquals( VirtualAppConstants.ICON_BINARY_REFERENCE, + iconParams.getData().getBinaryReference( SchemaNodePropertyNames.ICON ) ); + assertNull( iconParams.getData().getString( SchemaNodePropertyNames.RESOURCE ) ); + assertNotNull( iconParams.getBinaryAttachments().get( VirtualAppConstants.ICON_BINARY_REFERENCE ) ); + } + + @Test + void persist_schema_replaces_existing_cms() + { + final NodePath cmsPath = new NodePath( "/applications/myBundle/cms" ); + Mockito.when( this.nodeService.nodeExists( cmsPath ) ).thenReturn( true ); + + final Node stagingNode = stubCreate(); + + this.service.persistApplicationSchema( ApplicationKey.from( "myBundle" ), Map.of() ); + + // the new schema is fully built (staging created) before the old one is deleted, then swapped in by rename + final InOrder inOrder = Mockito.inOrder( this.nodeService ); + + final ArgumentCaptor createCaptor = ArgumentCaptor.forClass( CreateNodeParams.class ); + inOrder.verify( this.nodeService ).create( createCaptor.capture() ); + assertEquals( ApplicationRepoServiceImpl.CMS_STAGING_NAME, createCaptor.getValue().getName().toString() ); + assertEquals( new NodePath( "/applications/myBundle" ), createCaptor.getValue().getParent() ); + + final ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass( DeleteNodeParams.class ); + inOrder.verify( this.nodeService ).delete( deleteCaptor.capture() ); + assertEquals( cmsPath, deleteCaptor.getValue().getNodePath() ); + + final ArgumentCaptor moveCaptor = ArgumentCaptor.forClass( MoveNodeParams.class ); + inOrder.verify( this.nodeService ).move( moveCaptor.capture() ); + assertEquals( stagingNode.id(), moveCaptor.getValue().getNodeId() ); + assertEquals( VirtualAppConstants.CMS_ROOT_NAME, moveCaptor.getValue().getNewNodeName().toString() ); + } + + @Test + void persist_schema_failure_keeps_existing_schema() + { + final NodePath cmsPath = new NodePath( "/applications/myBundle/cms" ); + final NodePath stagingPath = new NodePath( "/applications/myBundle/" + ApplicationRepoServiceImpl.CMS_STAGING_NAME ); + Mockito.when( this.nodeService.nodeExists( cmsPath ) ).thenReturn( true ); + + final Node stagingNode = stagingNode(); + Mockito.when( this.nodeService.create( Mockito.any( CreateNodeParams.class ) ) ).thenAnswer( invocation -> { + final CreateNodeParams params = invocation.getArgument( 0 ); + if ( "mytype.yaml".equals( params.getName().toString() ) ) + { + throw new RuntimeException( "node layer failure" ); + } + return stagingNode; + } ); + + final Map resources = + Map.of( "content-types/mytype/mytype.yaml", ByteSource.wrap( "content-type".getBytes( StandardCharsets.UTF_8 ) ) ); + + assertThrows( RuntimeException.class, + () -> this.service.persistApplicationSchema( ApplicationKey.from( "myBundle" ), resources ) ); + + // the previously persisted schema is untouched: only the staging node is cleaned up + final ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass( DeleteNodeParams.class ); + Mockito.verify( this.nodeService ).delete( deleteCaptor.capture() ); + assertEquals( stagingPath, deleteCaptor.getValue().getNodePath() ); + Mockito.verify( this.nodeService, Mockito.never() ).move( Mockito.any( MoveNodeParams.class ) ); + } + + @Test + void persist_schema_removes_leftover_staging() + { + final NodePath stagingPath = new NodePath( "/applications/myBundle/" + ApplicationRepoServiceImpl.CMS_STAGING_NAME ); + Mockito.when( this.nodeService.nodeExists( stagingPath ) ).thenReturn( true ); + + stubCreate(); + + this.service.persistApplicationSchema( ApplicationKey.from( "myBundle" ), Map.of() ); + + final InOrder inOrder = Mockito.inOrder( this.nodeService ); + + final ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass( DeleteNodeParams.class ); + inOrder.verify( this.nodeService ).delete( deleteCaptor.capture() ); + assertEquals( stagingPath, deleteCaptor.getValue().getNodePath() ); + + inOrder.verify( this.nodeService ).create( Mockito.any( CreateNodeParams.class ) ); + inOrder.verify( this.nodeService ).move( Mockito.any( MoveNodeParams.class ) ); + } + + private Node stubCreate() + { + final Node stagingNode = stagingNode(); + Mockito.when( this.nodeService.create( Mockito.any( CreateNodeParams.class ) ) ).thenReturn( stagingNode ); + return stagingNode; + } + + private static Node stagingNode() + { + return Node.create() + .id( new NodeId() ) + .name( ApplicationRepoServiceImpl.CMS_STAGING_NAME ) + .parentPath( new NodePath( "/applications/myBundle" ) ) + .build(); + } + private AppInfo createApp() { var app = new AppInfo(); diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceImplTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceImplTest.java index f5df3711a66..8a9b7acbc47 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceImplTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceImplTest.java @@ -1,11 +1,14 @@ package com.enonic.xp.core.impl.app; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.stream.Stream; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -405,6 +408,142 @@ void install_global() verifyStartedEvent( application.getKey(), times( 1 ) ); } + @Test + void install_global_static_persists_schema() + { + final Node node = Node.create().id( NodeId.from( "mynode" ) ).parentPath( NodePath.ROOT ).name( "my.bundle" ).build(); + final String bundleName = "my.bundle"; + final ApplicationKey applicationKey = ApplicationKey.from( bundleName ); + + mockRepoCreateNode( node ); + mockRepoGetNode( node, bundleName ); + + final ByteSource byteSource = createStaticBundleSource( bundleName ); + + final Application application = this.service.installGlobalApplication( byteSource ); + + assertNotNull( application ); + assertFalse( this.service.isLocalApplication( applicationKey ) ); + + verify( this.repoService ).persistApplicationSchema( eq( applicationKey ), argThat( + resources -> resources.size() == 5 && "cms-descriptor".equals( readResource( resources, "cms.yaml" ) ) && + "content-type".equals( readResource( resources, "content-types/mytype/mytype.yaml" ) ) && + "".equals( readResource( resources, "content-types/mytype/mytype.svg" ) ) && + "macro".equals( readResource( resources, "macros/mymacro/mymacro.yaml" ) ) && + "phrases".equals( readResource( resources, "i18n/phrases/phrases_en.properties" ) ) ) ); + } + + @Test + void install_global_bundle_without_cms_descriptor_keeps_persisted_schema() + { + final Node node = Node.create().id( NodeId.from( "mynode" ) ).parentPath( NodePath.ROOT ).name( "my.bundle" ).build(); + final String bundleName = "my.bundle"; + + mockRepoCreateNode( node ); + mockRepoGetNode( node, bundleName ); + + // schema descriptors without cms/cms.yaml: the bundle does not own the schema, nothing is persisted (or removed) + this.service.installGlobalApplication( wrap( newBundle( bundleName, true ) + .addResource( "cms/content-types/mytype/mytype.yaml", stream( "content-type" ) ) + .addResource( "cms/parts/mypart/mypart.js", stream( "controller" ) ) + .build() ) ); + + verify( this.repoService, never() ).persistApplicationSchema( any(), any() ); + } + + @Test + void install_global_bundle_with_cms_descriptor_persists_schema() + { + final Node node = Node.create().id( NodeId.from( "mynode" ) ).parentPath( NodePath.ROOT ).name( "my.bundle" ).build(); + final String bundleName = "my.bundle"; + final ApplicationKey applicationKey = ApplicationKey.from( bundleName ); + + mockRepoCreateNode( node ); + mockRepoGetNode( node, bundleName ); + + this.service.installGlobalApplication( wrap( newBundle( bundleName, true ) + .addResource( "enonic.yaml", stream( "kind: \"Application\"\n" ) ) + .addResource( "cms/cms.yml", stream( "cms-descriptor" ) ) + .addResource( "cms/parts/mypart/mypart.yaml", stream( "part" ) ) + .addResource( "cms/parts/mypart/mypart.js", stream( "controller" ) ) + .build() ) ); + + verify( this.repoService ).persistApplicationSchema( eq( applicationKey ), argThat( + resources -> resources.size() == 2 && "cms-descriptor".equals( readResource( resources, "cms.yaml" ) ) && + "part".equals( readResource( resources, "parts/mypart/mypart.yaml" ) ) ) ); + } + + @Test + void install_global_unreadable_schema_changes_nothing() + { + final ByteSource bundleSource = wrap( newBundle( "my.bundle", true ) + .addResource( "enonic.yaml", stream( "kind: \"Application\"\n" ) ) + .addResource( "cms/cms.yaml", stream( "cms-descriptor" ) ) + .build() ); + + // readable while AppInfo is resolved (first open), unreadable when the schema is extracted + final ByteSource failingSource = new ByteSource() + { + private int opens; + + @Override + public InputStream openStream() + throws IOException + { + if ( ++opens > 1 ) + { + throw new IOException( "unreadable jar" ); + } + return bundleSource.openStream(); + } + }; + + assertThrows( ApplicationBundleException.class, () -> this.service.installGlobalApplication( failingSource ) ); + + // schema extraction fails before anything is changed: no node writes, no events, no bundle + verify( this.repoService, never() ).upsertApplicationNode( any(), any() ); + verify( this.repoService, never() ).persistApplicationSchema( any(), any() ); + verify( this.eventPublisher, never() ).publish( any() ); + assertNull( this.service.getInstalledApplication( ApplicationKey.from( "my.bundle" ) ) ); + } + + @Test + void install_local_static_does_not_persist_schema() + { + final Node node = Node.create().id( NodeId.from( "mynode" ) ).parentPath( NodePath.ROOT ).name( "my.bundle" ).build(); + final String bundleName = "my.bundle"; + + mockRepoCreateNode( node ); + mockRepoGetNode( node, bundleName ); + + final Application application = this.service.installLocalApplication( createStaticBundleSource( bundleName ) ); + + assertNotNull( application ); + assertTrue( this.service.isLocalApplication( application.getKey() ) ); + + verify( this.repoService, never() ).persistApplicationSchema( any(), any() ); + } + + @Test + void install_local_bundle_with_cms_descriptor_does_not_persist_schema() + { + final String bundleName = "my.bundle"; + + final Application application = this.service.installLocalApplication( wrap( newBundle( bundleName, true ) + .addResource( "enonic.yaml", stream( + "kind: \"Application\"\n" ) ) + .addResource( "cms/cms.yaml", stream( "cms-descriptor" ) ) + .addResource( "cms/parts/mypart/mypart.yaml", + stream( "part" ) ) + .build() ) ); + + assertNotNull( application ); + assertTrue( this.service.isLocalApplication( application.getKey() ) ); + + verify( this.repoService, never() ).persistApplicationSchema( any(), any() ); + verify( this.repoService, never() ).upsertApplicationNode( any(), any() ); + } + @Test void install_global_invalid() { @@ -924,8 +1063,23 @@ private ByteSource createBundleSource( final String bundleName ) private ByteSource createBundleSource( final String bundleName, final boolean isApp ) { - final InputStream in = newBundle( bundleName, isApp ).build(); + return wrap( newBundle( bundleName, isApp ).build() ); + } + + private ByteSource createStaticBundleSource( final String bundleName ) + { + return wrap( newBundle( bundleName, true ).addResource( "enonic.yaml", stream( "kind: \"Application\"\ntype: \"Static\"\n" ) ) + .addResource( "cms/cms.yaml", stream( "cms-descriptor" ) ) + .addResource( "cms/content-types/mytype/mytype.yml", stream( "content-type" ) ) + .addResource( "cms/content-types/mytype/mytype.svg", stream( "" ) ) + .addResource( "cms/macros/mymacro/mymacro.yaml", stream( "macro" ) ) + .addResource( "cms/i18n/phrases/phrases_en.properties", stream( "phrases" ) ) + .addResource( "i18n/phrases_en.properties", stream( "root-phrases" ) ) + .build() ); + } + private static ByteSource wrap( final InputStream in ) + { try { return ByteSource.wrap( ByteStreams.toByteArray( in ) ); @@ -936,6 +1090,28 @@ private ByteSource createBundleSource( final String bundleName, final boolean is } } + private static InputStream stream( final String content ) + { + return new ByteArrayInputStream( content.getBytes( StandardCharsets.UTF_8 ) ); + } + + private static String readResource( final Map resources, final String path ) + { + final ByteSource byteSource = resources.get( path ); + if ( byteSource == null ) + { + return null; + } + try + { + return byteSource.asCharSource( StandardCharsets.UTF_8 ).read(); + } + catch ( IOException e ) + { + throw new UncheckedIOException( e ); + } + } + private Bundle deployBundle( final String key ) { final InputStream in = newBundle( key, false ).build(); diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceSystemAppGuardsTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceSystemAppGuardsTest.java index 50e6a29fe54..e819ef4ba3d 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceSystemAppGuardsTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/ApplicationServiceSystemAppGuardsTest.java @@ -70,8 +70,10 @@ void deploy_installed_system_app_cannot_be_stopped() assertThat( installed ).isNotNull(); assertThat( installed.isSystem() ).isTrue(); - final Bundle bundle = getBundleContext().getBundle( SYSTEM_APP_NAME ); + // local applications are installed under a marked bundle location + final Bundle bundle = getBundleContext().getBundle( ApplicationHelper.toBundleLocation( key, true ) ); assertThat( bundle ).isNotNull(); + assertThat( ApplicationHelper.isLocalApplication( bundle ) ).isTrue(); assertThat( bundle.getState() ).isEqualTo( Bundle.ACTIVE ); adminContext().runWith( () -> { diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/SchemaResourcePathsTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/SchemaResourcePathsTest.java new file mode 100644 index 00000000000..95891352601 --- /dev/null +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/SchemaResourcePathsTest.java @@ -0,0 +1,56 @@ +package com.enonic.xp.core.impl.app; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaResourcePathsTest +{ + @ParameterizedTest + @ValueSource(strings = {"cms/cms.yaml", "cms/cms.yml", "/cms/cms.yaml", "cms/style/style.yaml", "cms/style/style.yml", + "cms/content-types/mytype/mytype.yaml", "/cms/content-types/mytype/mytype.yml", "cms/form-fragments/f/f.yaml", + "cms/mixins/m/m.yaml", "cms/parts/p/p.yaml", "cms/layouts/l/l.yaml", "cms/pages/pg/pg.yaml", "cms/macros/mc/mc.yml", + "cms/i18n/phrases/phrases.properties", "cms/i18n/phrases/phrases_en_US.properties", "/cms/i18n/phrases/phrases.properties", + "cms/content-types/mytype/mytype.svg", "cms/content-types/mytype/mytype.png", "cms/form-fragments/f/f.svg", "cms/mixins/m/m.png", + "cms/parts/p/p.svg", "cms/macros/mc/mc.png"}) + void schema_resource_paths( final String path ) + { + assertTrue( SchemaResourcePaths.isSchemaResourcePath( path ), path ); + } + + @ParameterizedTest + @ValueSource(strings = {"content-types/mytype/mytype.svg", "mytype.svg"}) + void icon_mime_type_svg( final String path ) + { + assertEquals( SchemaResourcePaths.SVG_MIME_TYPE, SchemaResourcePaths.iconMimeType( path ) ); + } + + @ParameterizedTest + @ValueSource(strings = {"parts/p/p.png", "p.png"}) + void icon_mime_type_png( final String path ) + { + assertEquals( SchemaResourcePaths.PNG_MIME_TYPE, SchemaResourcePaths.iconMimeType( path ) ); + } + + @ParameterizedTest + @ValueSource(strings = {"content-types/mytype/mytype.yaml", "i18n/phrases/phrases.properties", "cms.yaml"}) + void icon_mime_type_of_non_icon( final String path ) + { + assertNull( SchemaResourcePaths.iconMimeType( path ) ); + } + + @ParameterizedTest + @ValueSource(strings = {"cms/content-types/mytype/other.yaml", "cms/content-types/mytype/other.svg", "cms/pages/pg/pg.svg", + "cms/layouts/l/l.png", "cms/style/style.svg", "cms/cms.svg", + "cms/parts/p/p.js", "cms/unknown/u/u.yaml", "cms/content-types/mytype.yaml", "cms/style.yaml", "cms/style/other.yaml", + "i18n/phrases.properties", "i18n/phrases/phrases.properties", "cms/i18n/phrases.properties", "cms/i18n/phrases/nested/p.properties", + "cms/i18n/phrases/phrases.yaml", "assets/cms/cms.yaml", "site/content-types/mytype/mytype.yaml", "cms", "cms/"}) + void non_schema_resource_paths( final String path ) + { + assertFalse( SchemaResourcePaths.isSchemaResourcePath( path ), path ); + } +} diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParserTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParserTest.java index e88e336cb20..907e6e0d131 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParserTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/YmlApplicationDescriptorParserTest.java @@ -8,34 +8,70 @@ import com.enonic.xp.app.ApplicationDescriptor; import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationType; import com.enonic.xp.util.GenericValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class YmlApplicationDescriptorParserTest { + private static final ApplicationKey MYAPP = ApplicationKey.from( "myapp" ); + @Test void test() throws Exception { final String yml = readAsString( "/descriptors/application-descriptor.yml" ); - final ApplicationKey myapp = ApplicationKey.from( "myapp" ); - - final ApplicationDescriptor.Builder descriptorBuilder = YmlApplicationDescriptorParser.parse( yml, myapp ); - descriptorBuilder.key( myapp ); - - final ApplicationDescriptor descriptor = descriptorBuilder.build(); + final ApplicationDescriptor descriptor = parse( yml ); assertNotNull( descriptor ); - assertEquals( myapp, descriptor.getKey() ); + assertEquals( MYAPP, descriptor.getKey() ); assertEquals( "Brief description of the application", descriptor.getDescription() ); + assertEquals( ApplicationType.BUNDLE, descriptor.getType() ); final GenericValue schemaConfig = descriptor.getSchemaConfig(); assertEquals( "value_1", schemaConfig.property( "property_1" ).asString() ); assertEquals( "value_2", schemaConfig.property( "property_2" ).asString() ); } + @Test + void type_static() + { + final ApplicationDescriptor descriptor = parse( "kind: \"Application\"\ntype: \"Static\"\n" ); + assertEquals( ApplicationType.STATIC, descriptor.getType() ); + } + + @Test + void type_bundle() + { + final ApplicationDescriptor descriptor = parse( "kind: \"Application\"\ntype: \"Bundle\"\n" ); + assertEquals( ApplicationType.BUNDLE, descriptor.getType() ); + } + + @Test + void type_unknown() + { + final Exception ex = assertThrows( Exception.class, () -> parse( "kind: \"Application\"\ntype: \"Virtual\"\n" ) ); + assertTrue( ex.getMessage().contains( "Unknown application type \"Virtual\"" ), ex.getMessage() ); + } + + @Test + void type_case_sensitive() + { + final Exception ex = assertThrows( Exception.class, () -> parse( "kind: \"Application\"\ntype: \"static\"\n" ) ); + assertTrue( ex.getMessage().contains( "Unknown application type \"static\"" ), ex.getMessage() ); + } + + private static ApplicationDescriptor parse( final String yml ) + { + final ApplicationDescriptor.Builder descriptorBuilder = YmlApplicationDescriptorParser.parse( yml, MYAPP ); + descriptorBuilder.key( MYAPP ); + return descriptorBuilder.build(); + } + private String readAsString( final String name ) throws Exception { diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolverTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolverTest.java new file mode 100644 index 00000000000..b1289c5fc4b --- /dev/null +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/FilteredApplicationUrlResolverTest.java @@ -0,0 +1,56 @@ +package com.enonic.xp.core.impl.app.resolver; + +import java.util.Set; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import com.enonic.xp.resource.Resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FilteredApplicationUrlResolverTest +{ + @Test + void findFiles_filtered() + { + final ApplicationUrlResolver delegate = mock( ApplicationUrlResolver.class ); + when( delegate.findFiles() ).thenReturn( Set.of( "a.txt", "b.yaml" ) ); + + final Predicate include = path -> !path.endsWith( ".yaml" ); + final FilteredApplicationUrlResolver resolver = new FilteredApplicationUrlResolver( delegate, () -> include ); + + assertEquals( Set.of( "a.txt" ), resolver.findFiles() ); + } + + @Test + void findResource_included() + { + final ApplicationUrlResolver delegate = mock( ApplicationUrlResolver.class ); + final Resource resource = mock( Resource.class ); + when( delegate.findResource( "/a.txt" ) ).thenReturn( resource ); + + final Predicate include = path -> !path.endsWith( ".yaml" ); + final FilteredApplicationUrlResolver resolver = new FilteredApplicationUrlResolver( delegate, () -> include ); + + assertSame( resource, resolver.findResource( "/a.txt" ) ); + } + + @Test + void findResource_excluded() + { + final ApplicationUrlResolver delegate = mock( ApplicationUrlResolver.class ); + + final Predicate include = path -> !path.endsWith( ".yaml" ); + final FilteredApplicationUrlResolver resolver = new FilteredApplicationUrlResolver( delegate, () -> include ); + + assertNull( resolver.findResource( "/b.yaml" ) ); + verify( delegate, never() ).findResource( "/b.yaml" ); + } +} diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolverTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolverTest.java index d7c793c4006..1c598ab05e8 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolverTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resolver/NodeResourceApplicationUrlResolverTest.java @@ -1,24 +1,36 @@ package com.enonic.xp.core.impl.app.resolver; +import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.stream.Stream; import java.util.Set; +import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.google.common.io.ByteSource; + import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.context.ContextBuilder; +import com.enonic.xp.core.impl.app.VirtualAppConstants; +import com.enonic.xp.data.PropertyTree; +import com.enonic.xp.node.AttachedBinaries; +import com.enonic.xp.node.AttachedBinary; import com.enonic.xp.node.ListNodesParams; +import com.enonic.xp.node.Node; import com.enonic.xp.node.NodeId; import com.enonic.xp.node.NodeListEntry; import com.enonic.xp.node.NodePath; import com.enonic.xp.node.NodeService; +import com.enonic.xp.resource.Resource; +import com.enonic.xp.schema.SchemaNodePropertyNames; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -31,20 +43,12 @@ class NodeResourceApplicationUrlResolverTest @Mock private NodeService nodeService; - private NodeResourceApplicationUrlResolver resolver; - - @BeforeEach - void setup() - { - this.resolver = new NodeResourceApplicationUrlResolver( APP_KEY, this.nodeService ); - } - @Test void findFiles_lists_the_cms_subtree() { - when( this.nodeService.list( any() ) ).thenAnswer( invocation -> result( "/myapp/cms/content-types/mytype/content-types" ) ); + when( this.nodeService.list( any() ) ).thenAnswer( invocation -> result( "/myapp/cms/content-types/mytype/mytype.yaml" ) ); - this.resolver.findFiles(); + virtualAppResolver().findFiles(); final ArgumentCaptor params = ArgumentCaptor.forClass( ListNodesParams.class ); verify( this.nodeService ).list( params.capture() ); @@ -56,23 +60,122 @@ void findFiles_lists_the_cms_subtree() void findFiles_returns_resources_relative_to_the_application() { when( this.nodeService.list( any() ) ).thenAnswer( - invocation -> result( "/myapp/cms/content-types/mytype/content-types", "/myapp/cms/parts/mypart/parts" ) ); + invocation -> result( "/myapp/cms/content-types/mytype/mytype.yaml", "/myapp/cms/parts/mypart/mypart.yaml" ) ); - assertEquals( Set.of( "/cms/content-types/mytype/content-types", "/cms/parts/mypart/parts" ), this.resolver.findFiles() ); + assertEquals( Set.of( "/cms/content-types/mytype/mytype.yaml", "/cms/parts/mypart/mypart.yaml" ), virtualAppResolver().findFiles() ); } @Test void findFiles_skips_the_folders_on_the_way_to_a_resource() { - when( this.nodeService.list( any() ) ).thenAnswer( invocation -> result( "/myapp/cms/content-types", - "/myapp/cms/content-types/mytype", - "/myapp/cms/content-types/mytype/content-types" ) ); + when( this.nodeService.list( any() ) ).thenAnswer( invocation -> result( "/myapp/cms/content-types", "/myapp/cms/content-types/mytype", + "/myapp/cms/content-types/mytype/mytype.yaml", + "/myapp/cms/i18n", "/myapp/cms/i18n/phrases", + "/myapp/cms/i18n/phrases/phrases_en.properties" ) ); + + assertEquals( Set.of( "/cms/content-types/mytype/mytype.yaml", "/cms/i18n/phrases/phrases_en.properties" ), + virtualAppResolver().findFiles() ); + } - assertEquals( Set.of( "/cms/content-types/mytype/content-types" ), this.resolver.findFiles() ); + @Test + void findFiles_lists_cms_and_style_descriptors() + { + when( this.nodeService.list( any() ) ).thenAnswer( + invocation -> result( "/myapp/cms/cms.yaml", "/myapp/cms/style", "/myapp/cms/style/style.yaml" ) ); + + assertEquals( Set.of( "/cms/cms.yaml", "/cms/style/style.yaml" ), virtualAppResolver().findFiles() ); + } + + @Test + void findFiles_relative_to_a_nested_application_node() + { + when( this.nodeService.list( any() ) ).thenAnswer( + invocation -> result( "/applications/myapp/cms/content-types/mytype/mytype.yaml" ) ); + + final NodeResourceApplicationUrlResolver resolver = staticAppResolver(); + + assertEquals( Set.of( "/cms/content-types/mytype/mytype.yaml" ), resolver.findFiles() ); + + final ArgumentCaptor params = ArgumentCaptor.forClass( ListNodesParams.class ); + verify( this.nodeService ).list( params.capture() ); + assertEquals( new NodePath( "/applications/myapp/cms" ), params.getValue().getParentPath() ); + } + + @Test + void findResource_returns_the_resource_property() + { + final PropertyTree data = new PropertyTree(); + data.setString( SchemaNodePropertyNames.RESOURCE, "kind: \"ContentType\"" ); + final Node node = Node.create() + .id( new NodeId() ) + .parentPath( new NodePath( "/applications/myapp/cms/content-types/mytype" ) ) + .name( "mytype.yaml" ) + .data( data ) + .timestamp( Instant.now() ) + .build(); + when( this.nodeService.getByPath( new NodePath( "/applications/myapp/cms/content-types/mytype/mytype.yaml" ) ) ).thenReturn( node ); + + final Resource resource = staticAppResolver().findResource( "/cms/content-types/mytype/mytype.yaml" ); + + assertEquals( "myapp:/cms/content-types/mytype/mytype.yaml", resource.getKey().toString() ); + assertEquals( "kind: \"ContentType\"", resource.readString() ); + assertEquals( "node", resource.getResolverName() ); + assertTrue( resource.exists() ); + } + + @Test + void findResource_returns_the_attached_binary_for_icon_nodes() + { + final PropertyTree data = new PropertyTree(); + data.setString( SchemaNodePropertyNames.MIME_TYPE, "image/svg+xml" ); + data.setBinaryReference( SchemaNodePropertyNames.ICON, VirtualAppConstants.ICON_BINARY_REFERENCE ); + final NodeId nodeId = new NodeId(); + final Node node = Node.create() + .id( nodeId ) + .parentPath( new NodePath( "/applications/myapp/cms/content-types/mytype" ) ) + .name( "mytype.svg" ) + .data( data ) + .timestamp( Instant.now() ) + .attachedBinaries( AttachedBinaries.create() + .add( new AttachedBinary( VirtualAppConstants.ICON_BINARY_REFERENCE, "blobkey" ) ) + .build() ) + .build(); + when( this.nodeService.getByPath( new NodePath( "/applications/myapp/cms/content-types/mytype/mytype.svg" ) ) ).thenReturn( node ); + when( this.nodeService.getBinary( nodeId, VirtualAppConstants.ICON_BINARY_REFERENCE ) ).thenReturn( + ByteSource.wrap( "".getBytes( StandardCharsets.UTF_8 ) ) ); + + final Resource resource = staticAppResolver().findResource( "/cms/content-types/mytype/mytype.svg" ); + + assertEquals( "", resource.readString() ); + assertEquals( "node", resource.getResolverName() ); + } + + @Test + void findResource_returns_null_for_a_missing_node() + { + assertNull( staticAppResolver().findResource( "/cms/content-types/missing/missing.yaml" ) ); + } + + @Test + void findResource_returns_null_outside_cms() + { + assertNull( staticAppResolver().findResource( "/assets/app.js" ) ); + verify( this.nodeService, org.mockito.Mockito.never() ).getByPath( any() ); + } + + private NodeResourceApplicationUrlResolver virtualAppResolver() + { + return NodeResourceApplicationUrlResolver.forVirtualApp( APP_KEY, this.nodeService ); + } + + private NodeResourceApplicationUrlResolver staticAppResolver() + { + return new NodeResourceApplicationUrlResolver( APP_KEY, this.nodeService, new NodePath( "/applications/myapp" ), + () -> ContextBuilder.create().build() ); } private static Stream result( final String... paths ) { return Stream.of( paths ).map( path -> new NodeListEntry( new NodeId(), new NodePath( path ), Instant.now() ) ); } -} +} \ No newline at end of file diff --git a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resource/ResourceServiceImplTest.java b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resource/ResourceServiceImplTest.java index 582cf530a91..59d0ec98665 100644 --- a/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resource/ResourceServiceImplTest.java +++ b/modules/core/core-app/src/test/java/com/enonic/xp/core/impl/app/resource/ResourceServiceImplTest.java @@ -238,7 +238,7 @@ void testProcessResourceWithParticularResolver() assertNull( processResource( "segment1", "/cms/parts/a/a.yml", "1" ) ); final ApplicationUrlResolver applicationUrlResolver = - new NodeResourceApplicationUrlResolver( ApplicationKey.from( "myapp" ), nodeService ); + NodeResourceApplicationUrlResolver.forVirtualApp( ApplicationKey.from( "myapp" ), nodeService ); doReturn( Optional.of( applicationUrlResolver ) ).when( applicationFactoryService ) .findResolver( ApplicationKey.from( "myapp" ), "node" ); @@ -279,7 +279,7 @@ void testProcessProjectResource() when( nodeService.getByPath( new NodePath( "/myapp/cms/parts/my-part/my-part.yml" ) ) ).thenReturn( partSchemaNode ); final ApplicationUrlResolver applicationUrlResolver = - new NodeResourceApplicationUrlResolver( ApplicationKey.from( "myapp" ), nodeService ); + NodeResourceApplicationUrlResolver.forVirtualApp( ApplicationKey.from( "myapp" ), nodeService ); doReturn( Optional.of( applicationUrlResolver ) ).when( applicationFactoryService ) .findResolver( ApplicationKey.from( "myapp" ), null ); diff --git a/modules/core/core-jsonschema/src/main/schema/templates/application.schema.json b/modules/core/core-jsonschema/src/main/schema/templates/application.schema.json index a159a438ec2..04fbad31728 100644 --- a/modules/core/core-jsonschema/src/main/schema/templates/application.schema.json +++ b/modules/core/core-jsonschema/src/main/schema/templates/application.schema.json @@ -12,6 +12,15 @@ "type": "string", "const": "Application" }, + "type": { + "type": "string", + "enum": [ + "Static", + "Bundle" + ], + "default": "Bundle", + "description": "Application type. Defaults to Bundle." + }, "title": { "$ref": "#/$defs/localizedTextDef" }, diff --git a/modules/core/core-jsonschema/src/test/java/com/enonic/xp/core/jsonschema/ApplicationSchemaValidationTest.java b/modules/core/core-jsonschema/src/test/java/com/enonic/xp/core/jsonschema/ApplicationSchemaValidationTest.java index 5d56e017ece..67549e15ff4 100644 --- a/modules/core/core-jsonschema/src/test/java/com/enonic/xp/core/jsonschema/ApplicationSchemaValidationTest.java +++ b/modules/core/core-jsonschema/src/test/java/com/enonic/xp/core/jsonschema/ApplicationSchemaValidationTest.java @@ -30,6 +30,30 @@ void documentWithDescriptionIsValid() assertThat( validateYaml( schema, "fixtures/application/valid-with-description.yml" ) ).isEmpty(); } + @Test + void typeStaticIsValid() + { + assertThat( validateYaml( schema, "fixtures/application/valid-type-static.yml" ) ).isEmpty(); + } + + @Test + void typeBundleIsValid() + { + assertThat( validateYaml( schema, "fixtures/application/valid-type-bundle.yml" ) ).isEmpty(); + } + + @Test + void typeMustBeKnownValue() + { + assertThat( validateYaml( schema, "fixtures/application/invalid-type-unknown.yml" ) ).isNotEmpty(); + } + + @Test + void typeIsCaseSensitive() + { + assertThat( validateYaml( schema, "fixtures/application/invalid-type-lowercase.yml" ) ).isNotEmpty(); + } + @Test void descriptionMustBeString() { diff --git a/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-lowercase.yml b/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-lowercase.yml new file mode 100644 index 00000000000..a274b5be4b3 --- /dev/null +++ b/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-lowercase.yml @@ -0,0 +1,2 @@ +kind: "Application" +type: "static" \ No newline at end of file diff --git a/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-unknown.yml b/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-unknown.yml new file mode 100644 index 00000000000..56964d72567 --- /dev/null +++ b/modules/core/core-jsonschema/src/test/resources/fixtures/application/invalid-type-unknown.yml @@ -0,0 +1,2 @@ +kind: "Application" +type: "Virtual" \ No newline at end of file diff --git a/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-bundle.yml b/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-bundle.yml new file mode 100644 index 00000000000..16f990589d5 --- /dev/null +++ b/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-bundle.yml @@ -0,0 +1,2 @@ +kind: "Application" +type: "Bundle" \ No newline at end of file diff --git a/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-static.yml b/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-static.yml new file mode 100644 index 00000000000..6758a8a2db1 --- /dev/null +++ b/modules/core/core-jsonschema/src/test/resources/fixtures/application/valid-type-static.yml @@ -0,0 +1,2 @@ +kind: "Application" +type: "Static" \ No newline at end of file diff --git a/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/ApplicationServiceTest.java b/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/ApplicationServiceTest.java index 7e46ea4945d..988aea523b3 100644 --- a/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/ApplicationServiceTest.java +++ b/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/ApplicationServiceTest.java @@ -1,11 +1,17 @@ package com.enonic.xp.core.app; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.Executors; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import org.apache.felix.framework.Felix; import org.junit.jupiter.api.AfterEach; @@ -20,6 +26,7 @@ import com.google.common.io.ByteStreams; import com.enonic.xp.app.Application; +import com.enonic.xp.app.ApplicationKey; import com.enonic.xp.app.ApplicationService; import com.enonic.xp.audit.AuditLogService; import com.enonic.xp.context.Context; @@ -36,26 +43,41 @@ import com.enonic.xp.core.impl.app.ApplicationRepoServiceImpl; import com.enonic.xp.core.impl.app.ApplicationServiceImpl; import com.enonic.xp.core.impl.app.VirtualAppService; +import com.enonic.xp.core.impl.app.resource.ResourceServiceImpl; import com.enonic.xp.core.impl.event.EventPublisherImpl; import com.enonic.xp.node.Node; import com.enonic.xp.node.NodePath; +import com.enonic.xp.resource.Resource; +import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceService; import com.enonic.xp.security.RoleKeys; import com.enonic.xp.security.SystemConstants; import com.enonic.xp.security.User; import com.enonic.xp.security.auth.AuthenticationInfo; +import com.enonic.xp.util.BinaryReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; class ApplicationServiceTest extends AbstractNodeTest { + private static final String STATIC_DESCRIPTOR = "kind: \"Application\"\ntype: \"Static\"\n"; + + private static final String BUNDLE_DESCRIPTOR = "kind: \"Application\"\n"; + @TempDir public Path felixTempFolder; private ApplicationService applicationService; + private ResourceService resourceService; + private Felix felix; @BeforeEach @@ -78,6 +100,8 @@ void setUp() new ApplicationFactoryServiceImpl( bundleContext, nodeService, appConfig ); applicationFactoryService.activate(); + this.resourceService = new ResourceServiceImpl( applicationFactoryService ); + ApplicationAuditLogSupportImpl applicationAuditLogSupport = new ApplicationAuditLogSupportImpl( mock( AuditLogService.class ) ); applicationAuditLogSupport.activate( appConfig ); @@ -125,6 +149,365 @@ void testUpdate() } ); } + @Test + void installGlobalStaticApplicationPersistsSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yml", "kind: \"ContentType\"\ndisplayName: \"My type\"", // + "cms/content-types/mytype/mytype.svg", "", // + "cms/i18n/phrases/phrases_en.properties", "key=value", // + "i18n/phrases_en.properties", "root=value", // + "assets/app.js", "console.log()" ) ) ); + + // schema resources are persisted below the application node in system-repo + assertEquals( "kind: \"CMS\"", schemaNode( "staticapp", "cms.yaml" ).data().getString( "resource" ) ); + assertEquals( "kind: \"ContentType\"\ndisplayName: \"My type\"", + schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ).data().getString( "resource" ) ); + assertEquals( "key=value", schemaNode( "staticapp", "i18n/phrases/phrases_en.properties" ).data().getString( "resource" ) ); + // icons are persisted as node binaries + final Node iconNode = schemaNode( "staticapp", "content-types/mytype/mytype.svg" ); + assertEquals( "image/svg+xml", iconNode.data().getString( "mimeType" ) ); + assertNotNull( iconNode.getAttachedBinaries().getByBinaryReference( BinaryReference.from( "icon" ) ) ); + // resources outside cms are not persisted + assertNull( appChildNode( "staticapp", "i18n" ) ); + assertNull( appChildNode( "staticapp", "assets" ) ); + + // descriptors are served from nodes (the bundle contained .yml, node is normalized to .yaml) + final Resource contentType = resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ); + assertTrue( contentType.exists() ); + assertEquals( "node", contentType.getResolverName() ); + assertEquals( "kind: \"ContentType\"\ndisplayName: \"My type\"", contentType.readString() ); + + final Resource cms = resourceService.getResource( ResourceKey.from( appKey, "/cms/cms.yaml" ) ); + assertEquals( "node", cms.getResolverName() ); + + final Resource phrases = resourceService.getResource( ResourceKey.from( appKey, "/cms/i18n/phrases/phrases_en.properties" ) ); + assertEquals( "node", phrases.getResolverName() ); + assertEquals( "key=value", phrases.readString() ); + + // the bundle's own schema descriptor is hidden + assertFalse( resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yml" ) ).exists() ); + + // the icon is served from the node as well + final Resource icon = resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.svg" ) ); + assertEquals( "node", icon.getResolverName() ); + assertEquals( "", icon.readString() ); + + // non-schema resources are still served from the bundle + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/assets/app.js" ) ).getResolverName() ); + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/i18n/phrases_en.properties" ) ).getResolverName() ); + + assertTrue( resourceService.findFiles( appKey, "^/cms/.*\\.yaml$" ) + .contains( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ) ); + assertFalse( resourceService.findFiles( appKey, "^/cms/.*\\.yml$" ) + .contains( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yml" ) ) ); + } ); + + // persisted schema is readable without any privileges (e.g. portal rendering) + final Resource anonymous = ContextBuilder.from( ContextAccessor.current() ) + .authInfo( AuthenticationInfo.unAuthenticated() ) + .build() + .callWith( () -> resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ) ); + assertEquals( "node", anonymous.getResolverName() ); + assertTrue( anonymous.exists() ); + } + + @Test + void reinstallStaticApplicationResetsSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"", // + "cms/i18n/phrases/phrases_en.properties", "key=value" ) ) ); + + final Node appNode = appNode( "staticapp" ); + assertNotNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.1", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/newtype/newtype.yaml", "kind: \"ContentType\"" ) ) ); + + assertEquals( appNode.id(), appNode( "staticapp" ).id() ); + assertNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + assertNull( schemaNode( "staticapp", "i18n/phrases/phrases_en.properties" ) ); + assertNotNull( schemaNode( "staticapp", "content-types/newtype/newtype.yaml" ) ); + + assertFalse( resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).exists() ); + assertEquals( "node", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/newtype/newtype.yaml" ) ).getResolverName() ); + } ); + } + + @Test + void reinstallAsBundleWithCmsDescriptorReplacesSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + + // the new version is not Static, but ships cms/cms.yaml: it owns the schema, the persisted one is replaced + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.1", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/newtype/newtype.yaml", "kind: \"ContentType\"", // + "cms/parts/mypart/mypart.yaml", "kind: \"Part\"", // + "cms/parts/mypart/mypart.js", "exports.get = function() {}" ) ) ); + + assertNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + assertNotNull( schemaNode( "staticapp", "cms.yaml" ) ); + assertNotNull( schemaNode( "staticapp", "content-types/newtype/newtype.yaml" ) ); + assertNotNull( schemaNode( "staticapp", "parts/mypart/mypart.yaml" ) ); + assertNull( schemaNode( "staticapp", "parts/mypart/mypart.js" ) ); + + assertFalse( resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).exists() ); + assertEquals( "node", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/newtype/newtype.yaml" ) ).getResolverName() ); + assertEquals( "node", resourceService.getResource( ResourceKey.from( appKey, "/cms/parts/mypart/mypart.yaml" ) ).getResolverName() ); + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/cms/parts/mypart/mypart.js" ) ).getResolverName() ); + } ); + } + + @Test + void reinstallAsBundleWithoutCmsDescriptorKeepsSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + + // the new version ships logic only (no cms/cms.yaml): the persisted schema stays and is still served + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.1", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/parts/mypart/mypart.js", "exports.get = function() {}", // + "assets/app.js", "console.log()" ) ) ); + + assertNotNull( appNode( "staticapp" ) ); + assertNotNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + + final Resource contentType = resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ); + assertTrue( contentType.exists() ); + assertEquals( "node", contentType.getResolverName() ); + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/cms/parts/mypart/mypart.js" ) ).getResolverName() ); + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/assets/app.js" ) ).getResolverName() ); + } ); + } + + @Test + void reinstallWithBrokenSchemaKeepsPersistedSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "brokenschemaapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "brokenschemaapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( schemaNode( "brokenschemaapp", "content-types/mytype/mytype.yaml" ) ); + + // "my?type" passes the schema resource pattern but is not a valid node name: persisting the new schema fails + // while it is being staged, so the previously persisted schema survives and the staging leftover is cleaned up + assertThrows( RuntimeException.class, () -> applicationService.installGlobalApplication( + createAppSource( "brokenschemaapp", "1.0.1", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/my?type/my?type.yaml", "kind: \"ContentType\"" ) ) ) ); + + assertNotNull( schemaNode( "brokenschemaapp", "content-types/mytype/mytype.yaml" ) ); + assertNull( appChildNode( "brokenschemaapp", "cms_staging" ) ); + + // reinstalling a fixed version repairs the application: the schema is replaced and served + applicationService.installGlobalApplication( createAppSource( "brokenschemaapp", "1.0.2", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/newtype/newtype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNull( schemaNode( "brokenschemaapp", "content-types/mytype/mytype.yaml" ) ); + assertNotNull( schemaNode( "brokenschemaapp", "content-types/newtype/newtype.yaml" ) ); + assertEquals( "node", resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/newtype/newtype.yaml" ) ) + .getResolverName() ); + } ); + } + + @Test + void installGlobalBundleApplicationWithCmsDescriptorPersistsSchema() + { + // own name: the repository is shared by the tests of this class, and a persisted schema outlives a bundle without cms/cms.yaml + final ApplicationKey appKey = ApplicationKey.from( "schemabundleapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "schemabundleapp", "1.0.0", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"", // + "cms/parts/mypart/mypart.yaml", "kind: \"Part\"", // + "cms/parts/mypart/mypart.js", "exports.get = function() {}" ) ) ); + + assertNotNull( appNode( "schemabundleapp" ) ); + assertNotNull( schemaNode( "schemabundleapp", "cms.yaml" ) ); + assertNotNull( schemaNode( "schemabundleapp", "content-types/mytype/mytype.yaml" ) ); + assertNotNull( schemaNode( "schemabundleapp", "parts/mypart/mypart.yaml" ) ); + // controllers are not schema resources + assertNull( schemaNode( "schemabundleapp", "parts/mypart/mypart.js" ) ); + + assertEquals( "node", resourceService.getResource( ResourceKey.from( appKey, "/cms/cms.yaml" ) ).getResolverName() ); + assertEquals( "node", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).getResolverName() ); + assertEquals( "node", resourceService.getResource( ResourceKey.from( appKey, "/cms/parts/mypart/mypart.yaml" ) ).getResolverName() ); + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/cms/parts/mypart/mypart.js" ) ).getResolverName() ); + } ); + } + + @Test + void uninstallStaticApplicationRemovesSchema() + { + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + + applicationService.uninstallApplication( ApplicationKey.from( "staticapp" ) ); + + assertNull( appNode( "staticapp" ) ); + assertNull( schemaNode( "staticapp", "content-types/mytype/mytype.yaml" ) ); + } ); + } + + @Test + void installGlobalBundleApplicationDoesNotPersistSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "bundleapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "bundleapp", "1.0.0", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( appNode( "bundleapp" ) ); + assertNull( appChildNode( "bundleapp", "cms" ) ); + + final Resource contentType = resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ); + assertTrue( contentType.exists() ); + assertEquals( "bundle", contentType.getResolverName() ); + } ); + } + + @Test + void installLocalStaticApplicationDoesNotPersistSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "localapp" ); + + adminContext().runWith( () -> { + applicationService.installLocalApplication( createAppSource( "localapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNull( appNode( "localapp" ) ); + + // no persisted schema: the static application falls back to the bundle + final Resource contentType = resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ); + assertTrue( contentType.exists() ); + assertEquals( "bundle", contentType.getResolverName() ); + } ); + } + + @Test + void localApplicationWithCmsDescriptorOverridesPersistedSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "overriddenapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "overriddenapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + assertNotNull( schemaNode( "overriddenapp", "content-types/mytype/mytype.yaml" ) ); + assertEquals( "node", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).getResolverName() ); + + // a local build of the same application, shipping its own schema, is deployed on top of the global one + applicationService.installLocalApplication( createAppSource( "overriddenapp", "1.0.1-SNAPSHOT", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/othertype/othertype.yaml", "kind: \"ContentType\"" ) ) ); + + assertTrue( applicationService.isLocalApplication( appKey ) ); + + // the persisted schema is untouched... + assertNotNull( schemaNode( "overriddenapp", "content-types/mytype/mytype.yaml" ) ); + // ...but ignored: the local bundle is the only schema source + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/cms/cms.yaml" ) ).getResolverName() ); + assertEquals( "bundle", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/othertype/othertype.yaml" ) ).getResolverName() ); + assertFalse( resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).exists() ); + + // removing the local application brings the stored one, and its persisted schema, back + applicationService.uninstallLocalApplication( appKey ); + + assertFalse( applicationService.isLocalApplication( appKey ) ); + assertEquals( "node", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).getResolverName() ); + assertFalse( resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/othertype/othertype.yaml" ) ).exists() ); + } ); + } + + @Test + void installLocalBundleApplicationWithCmsDescriptorDoesNotPersistSchema() + { + final ApplicationKey appKey = ApplicationKey.from( "localbundleapp" ); + + adminContext().runWith( () -> { + applicationService.installLocalApplication( createAppSource( "localbundleapp", "1.0.0", Map.of( // + "enonic.yaml", BUNDLE_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yaml", "kind: \"ContentType\"" ) ) ); + + // local applications are never stored: no application node, no persisted schema + assertNull( appNode( "localbundleapp" ) ); + + assertEquals( "bundle", resourceService.getResource( ResourceKey.from( appKey, "/cms/cms.yaml" ) ).getResolverName() ); + assertEquals( "bundle", + resourceService.getResource( ResourceKey.from( appKey, "/cms/content-types/mytype/mytype.yaml" ) ).getResolverName() ); + } ); + } + + private Node appNode( final String appName ) + { + return systemRepoContext().callWith( + () -> nodeService.getByPath( NodePath.create( NodePath.ROOT ).addElement( "applications" ).addElement( appName ).build() ) ); + } + + private Node appChildNode( final String appName, final String childName ) + { + return systemRepoContext().callWith( () -> nodeService.getByPath( + NodePath.create( NodePath.ROOT ).addElement( "applications" ).addElement( appName ).addElement( childName ).build() ) ); + } + + private Node schemaNode( final String appName, final String cmsRelativePath ) + { + return systemRepoContext().callWith( + () -> nodeService.getByPath( new NodePath( "/applications/" + appName + "/cms/" + cmsRelativePath ) ) ); + } + private Felix createFelixInstance( final Path cacheDir ) { Map config = new HashMap<>(); @@ -162,4 +545,28 @@ private ByteSource createByteSource( String appVersion ) .build() ) ); } -} + private static ByteSource createAppSource( final String name, final String version, final Map resources ) + { + final Manifest manifest = new Manifest(); + manifest.getMainAttributes().put( Attributes.Name.MANIFEST_VERSION, "1.0" ); + manifest.getMainAttributes().putValue( Constants.BUNDLE_SYMBOLICNAME, name ); + manifest.getMainAttributes().putValue( Constants.BUNDLE_VERSION, version ); + manifest.getMainAttributes().putValue( "X-Bundle-Type", "application" ); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream( out, manifest )) + { + for ( final Map.Entry entry : resources.entrySet() ) + { + jar.putNextEntry( new JarEntry( entry.getKey() ) ); + jar.write( entry.getValue().getBytes( StandardCharsets.UTF_8 ) ); + jar.closeEntry(); + } + } + catch ( IOException e ) + { + throw new IllegalStateException( e ); + } + return ByteSource.wrap( out.toByteArray() ); + } +} \ No newline at end of file diff --git a/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/StaticApplicationSchemaLookupTest.java b/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/StaticApplicationSchemaLookupTest.java new file mode 100644 index 00000000000..50897c9037c --- /dev/null +++ b/modules/itest/itest-core/src/test/java/com/enonic/xp/core/app/StaticApplicationSchemaLookupTest.java @@ -0,0 +1,204 @@ +package com.enonic.xp.core.app; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.apache.felix.framework.Felix; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.osgi.framework.BundleContext; +import org.osgi.framework.Constants; + +import com.google.common.io.ByteSource; + +import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.app.ApplicationService; +import com.enonic.xp.audit.AuditLogService; +import com.enonic.xp.context.Context; +import com.enonic.xp.context.ContextAccessor; +import com.enonic.xp.context.ContextBuilder; +import com.enonic.xp.core.AbstractNodeTest; +import com.enonic.xp.core.impl.app.AppConfig; +import com.enonic.xp.core.impl.app.AppFilterServiceImpl; +import com.enonic.xp.core.impl.app.ApplicationAuditLogSupportImpl; +import com.enonic.xp.core.impl.app.ApplicationFactoryServiceImpl; +import com.enonic.xp.core.impl.app.ApplicationListenerHub; +import com.enonic.xp.core.impl.app.ApplicationRegistryImpl; +import com.enonic.xp.core.impl.app.ApplicationRepoInitializer; +import com.enonic.xp.core.impl.app.ApplicationRepoServiceImpl; +import com.enonic.xp.core.impl.app.ApplicationServiceImpl; +import com.enonic.xp.core.impl.app.VirtualAppService; +import com.enonic.xp.core.impl.app.resource.ResourceServiceImpl; +import com.enonic.xp.core.impl.content.schema.CmsFormFragmentServiceImpl; +import com.enonic.xp.core.impl.content.schema.ContentTypeServiceImpl; +import com.enonic.xp.core.impl.event.EventPublisherImpl; +import com.enonic.xp.resource.ResourceService; +import com.enonic.xp.schema.content.ContentType; +import com.enonic.xp.schema.content.ContentTypeService; +import com.enonic.xp.schema.content.ContentTypes; +import com.enonic.xp.security.RoleKeys; +import com.enonic.xp.security.User; +import com.enonic.xp.security.auth.AuthenticationInfo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that schema services see the schemas of a {@code type: Static} application, which are served from nodes. + */ +class StaticApplicationSchemaLookupTest + extends AbstractNodeTest +{ + private static final String STATIC_DESCRIPTOR = "kind: \"Application\"\ntype: \"Static\"\n"; + + private static final String CONTENT_TYPE = "kind: \"ContentType\"\nsuperType: \"base:structured\"\ntitle:\n text: \"My type\"\n"; + + private static final String INVALID_CONTENT_TYPE = "kind: \"ContentType\"\ndisplayName: \"Unknown property\"\n"; + + @TempDir + public Path felixTempFolder; + + private ApplicationService applicationService; + + private ResourceService resourceService; + + private ContentTypeService contentTypeService; + + private Felix felix; + + @BeforeEach + void setUp() + throws Exception + { + final Path cacheDir = Files.createDirectory( this.felixTempFolder.resolve( "cache" ) ).toAbsolutePath(); + + final Map config = new HashMap<>(); + config.put( Constants.FRAMEWORK_STORAGE, cacheDir.toString() ); + config.put( Constants.FRAMEWORK_STORAGE_CLEAN, Constants.FRAMEWORK_STORAGE_CLEAN_ONFIRSTINIT ); + this.felix = new Felix( config ); + this.felix.start(); + + final AppConfig appConfig = mock( AppConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); + + final ApplicationRepoServiceImpl repoService = new ApplicationRepoServiceImpl( nodeService ); + ApplicationRepoInitializer.create().setIndexService( indexService ).setNodeService( nodeService ).build().initialize(); + + final BundleContext bundleContext = felix.getBundleContext(); + + final ApplicationFactoryServiceImpl applicationFactoryService = + new ApplicationFactoryServiceImpl( bundleContext, nodeService, appConfig ); + applicationFactoryService.activate(); + + this.resourceService = new ResourceServiceImpl( applicationFactoryService ); + + final ApplicationAuditLogSupportImpl auditLogSupport = new ApplicationAuditLogSupportImpl( mock( AuditLogService.class ) ); + auditLogSupport.activate( appConfig ); + + this.applicationService = new ApplicationServiceImpl( + new ApplicationRegistryImpl( bundleContext, new ApplicationListenerHub(), applicationFactoryService ), repoService, + new EventPublisherImpl( Executors.newSingleThreadExecutor() ), new AppFilterServiceImpl( appConfig ), + new VirtualAppService( nodeService ), auditLogSupport ); + + this.contentTypeService = + new ContentTypeServiceImpl( resourceService, applicationService, new CmsFormFragmentServiceImpl( resourceService ) ); + } + + @AfterEach + void destroy() + throws Exception + { + this.felix.stop(); + this.felix.waitForStop( 10_000 ); + } + + @Test + void getByApplication_returns_persisted_content_types() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yaml", CONTENT_TYPE, // + "cms/content-types/othertype/othertype.yml", CONTENT_TYPE ) ) ); + + final ContentTypes byApplication = contentTypeService.getByApplication( appKey ); + + assertEquals( List.of( "staticapp:mytype", "staticapp:othertype" ), + byApplication.stream().map( type -> type.getName().toString() ).sorted().toList() ); + assertTrue( byApplication.stream().map( ContentType::getTitle ).allMatch( "My type"::equals ) ); + } ); + } + + @Test + void getByApplication_skips_content_types_that_fail_to_parse() + { + final ApplicationKey appKey = ApplicationKey.from( "staticapp" ); + + adminContext().runWith( () -> { + applicationService.installGlobalApplication( createAppSource( "staticapp", "1.0.0", Map.of( // + "enonic.yaml", STATIC_DESCRIPTOR, // + "cms/cms.yaml", "kind: \"CMS\"", // + "cms/content-types/mytype/mytype.yaml", CONTENT_TYPE, // + "cms/content-types/broken/broken.yaml", INVALID_CONTENT_TYPE ) ) ); + + // the descriptor is discovered, but loading it fails and ContentTypeRegistry logs the error and drops the type + assertEquals( List.of( "staticapp:broken", "staticapp:mytype" ), resourceService.findFiles( appKey, + "^/cms/content-types/(?[^/]+)/\\k\\.(?:yaml|yml)$" ) + .stream() + .map( key -> key.getApplicationKey() + ":" + key.getPath().split( "/" )[3] ) + .sorted() + .toList() ); + + assertEquals( List.of( "staticapp:mytype" ), + contentTypeService.getByApplication( appKey ).stream().map( type -> type.getName().toString() ).toList() ); + } ); + } + + private Context adminContext() + { + return ContextBuilder.from( ContextAccessor.current() ) + .authInfo( AuthenticationInfo.create().principals( RoleKeys.ADMIN ).user( User.anonymous() ).build() ) + .build(); + } + + private static ByteSource createAppSource( final String name, final String version, final Map resources ) + { + final Manifest manifest = new Manifest(); + manifest.getMainAttributes().put( Attributes.Name.MANIFEST_VERSION, "1.0" ); + manifest.getMainAttributes().putValue( Constants.BUNDLE_SYMBOLICNAME, name ); + manifest.getMainAttributes().putValue( Constants.BUNDLE_VERSION, version ); + manifest.getMainAttributes().putValue( "X-Bundle-Type", "application" ); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream( out, manifest )) + { + for ( final Map.Entry entry : resources.entrySet() ) + { + jar.putNextEntry( new JarEntry( entry.getKey() ) ); + jar.write( entry.getValue().getBytes( StandardCharsets.UTF_8 ) ); + jar.closeEntry(); + } + } + catch ( IOException e ) + { + throw new IllegalStateException( e ); + } + return ByteSource.wrap( out.toByteArray() ); + } +}