MvcConfiguration.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.configuration;

import lombok.RequiredArgsConstructor;
import org.entrystore.rest.springboot.filter.IfUnmodifiedSinceInterceptor;
import org.entrystore.rest.springboot.filter.MultipartHandlerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.accept.HeaderContentNegotiationStrategy;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@RequiredArgsConstructor
public class MvcConfiguration implements WebMvcConfigurer {

	private final MvcAsyncConfiguration asyncConfig;

	private final IfUnmodifiedSinceInterceptor ifUnmodifiedSinceInterceptor;

	private final MultipartHandlerInterceptor multipartHandlerInterceptor;

	/**
	 * Negotiates on the Accept header, and on entry URIs also on the {@code format} parameter. Other handlers
	 * that honour {@code format} read it themselves: setting strategies disables the configurer's parameter
	 * and default-type options.
	 */
	@Override
	public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
		configurer.strategies(List.of(
				new EntryEndpointContentNegotiationStrategy(new HeaderContentNegotiationStrategy())));
	}

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(multipartHandlerInterceptor);
		registry.addInterceptor(ifUnmodifiedSinceInterceptor)
				.addPathPatterns(IfUnmodifiedSinceInterceptor.PATH_PATTERNS);
	}

	@Override
	public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
		configurer.setTaskExecutor(mvcAsyncTaskExecutor());
		configurer.setDefaultTimeout(asyncConfig.defaultTimeoutMs());
	}

	/**
	 * Bounded executor for Spring MVC async dispatch. The default executor
	 * ({@code SimpleAsyncTaskExecutor}) creates a new platform thread per request without bound or
	 * pool. For anonymous endpoints that return {@code StreamingResponseBody} (e.g. {@code /sparql}),
	 * that lets an attacker pin thousands of native threads in parallel — bypassing per-request size,
	 * body, and time caps. This bounded pool rejects the overflow with {@code AbortPolicy}
	 * ({@code RejectedExecutionException}, mapped to 503 by {@code AppExceptionHandler}) instead.
	 * <p>
	 * Declared as a bean so the container owns its lifecycle: {@code afterPropertiesSet()} calls
	 * {@code initialize()} on startup and {@code destroy()} shuts the pool down on context close,
	 * preventing the non-daemon {@code mvc-async-*} threads from surviving graceful shutdown.
	 */
	@Bean
	ThreadPoolTaskExecutor mvcAsyncTaskExecutor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setCorePoolSize(asyncConfig.corePoolSize());
		executor.setMaxPoolSize(asyncConfig.maxPoolSize());
		executor.setQueueCapacity(asyncConfig.queueCapacity());
		executor.setThreadNamePrefix("mvc-async-");
		executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
		return executor;
	}
}