View Javadoc
1   /*
2    * Copyright (C) 2024 Provincie Zeeland
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   
7   package nl.b3p.planmonitorwonen.api.configuration;
8   
9   import com.fasterxml.jackson.annotation.JsonTypeInfo;
10  import java.lang.invoke.MethodHandles;
11  import java.nio.charset.StandardCharsets;
12  import javax.sql.DataSource;
13  import org.jspecify.annotations.NonNull;
14  import org.slf4j.Logger;
15  import org.slf4j.LoggerFactory;
16  import org.springframework.beans.factory.BeanClassLoaderAware;
17  import org.springframework.beans.factory.annotation.Qualifier;
18  import org.springframework.beans.factory.annotation.Value;
19  import org.springframework.boot.jdbc.DataSourceBuilder;
20  import org.springframework.context.annotation.Bean;
21  import org.springframework.context.annotation.Configuration;
22  import org.springframework.context.annotation.Profile;
23  import org.springframework.core.convert.ConversionFailedException;
24  import org.springframework.core.convert.ConversionService;
25  import org.springframework.core.convert.TypeDescriptor;
26  import org.springframework.core.convert.support.GenericConversionService;
27  import org.springframework.jdbc.core.simple.JdbcClient;
28  import org.springframework.jdbc.datasource.DataSourceTransactionManager;
29  import org.springframework.security.jackson.SecurityJacksonModules;
30  import org.springframework.session.config.SessionRepositoryCustomizer;
31  import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
32  import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource;
33  import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession;
34  import org.springframework.transaction.PlatformTransactionManager;
35  import tools.jackson.core.JacksonException;
36  import tools.jackson.core.StreamReadFeature;
37  import tools.jackson.databind.DefaultTyping;
38  import tools.jackson.databind.SerializationFeature;
39  import tools.jackson.databind.json.JsonMapper;
40  import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
41  
42  @Configuration
43  @EnableJdbcHttpSession
44  @Profile("!test")
45  public class JdbcSessionConfiguration implements BeanClassLoaderAware {
46    private static final Logger logger =
47        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
48  
49    @Value("${spring.datasource.url}")
50    private String dataSourceUrl;
51  
52    @Value("${spring.datasource.username}")
53    private String dataSourceUsername;
54  
55    @Value("${spring.datasource.password}")
56    private String dataSourcePassword;
57  
58    @Value("${tailormap.datasource.url}")
59    private String sessionDataSourceUrl;
60  
61    @Value("${tailormap.datasource.username}")
62    private String sessionDataSourceUsername;
63  
64    @Value("${tailormap.datasource.password}")
65    private String sessionDataSourcePassword;
66  
67    private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
68  INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
69  VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
70  """;
71  
72    private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
73  UPDATE %TABLE_NAME%_ATTRIBUTES
74  SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb
75  WHERE SESSION_PRIMARY_ID = ?
76  AND ATTRIBUTE_NAME = ?
77  """;
78  
79    private ClassLoader classLoader;
80  
81    @Override
82    public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
83      this.classLoader = classLoader;
84    }
85  
86    @Bean
87    public DataSource dataSource() {
88      DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
89      dataSourceBuilder.url(dataSourceUrl);
90      dataSourceBuilder.username(dataSourceUsername);
91      dataSourceBuilder.password(dataSourcePassword);
92      return dataSourceBuilder.build();
93    }
94  
95    @Bean
96    public JdbcClient jdbcClient(DataSource dataSource) {
97      return JdbcClient.create(dataSource);
98    }
99  
100   @Bean(name = "tailormapJdbcClient")
101   public JdbcClient tailormapJdbcClient(@Qualifier("tailormapDataSource") DataSource data) {
102     return JdbcClient.create(data);
103   }
104 
105   @Bean
106   SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
107     return (sessionRepository) -> {
108       sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
109       sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
110     };
111   }
112 
113   @Bean(name = {"springSessionDataSource", "tailormapDataSource"})
114   @SpringSessionDataSource
115   public DataSource sessionDataSource() {
116     DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
117     dataSourceBuilder.url(sessionDataSourceUrl);
118     dataSourceBuilder.username(sessionDataSourceUsername);
119     dataSourceBuilder.password(sessionDataSourcePassword);
120     return dataSourceBuilder.build();
121   }
122 
123   @Bean(name = "springSessionTransactionOperations")
124   public PlatformTransactionManager springSessionTransactionOperations(
125       @Qualifier("springSessionDataSource") DataSource springSessionDatasource) {
126     return new DataSourceTransactionManager(springSessionDatasource);
127   }
128 
129   @Bean("springSessionConversionService")
130   public ConversionService springSessionConversionService() {
131     BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
132         .allowIfSubType("org.tailormap.api.security.")
133         .allowIfSubType("org.springframework.security.")
134         .allowIfSubType("java.util.")
135         .allowIfSubType(java.lang.Number.class)
136         .allowIfSubType("java.time.")
137         .allowIfBaseType(Object.class);
138 
139     JsonMapper mapper = JsonMapper.builder()
140         .configure(
141             StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
142             (logger.isDebugEnabled() || logger.isTraceEnabled()))
143         .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
144         .addMixIn(
145             org.tailormap.api.security.TailormapUserDetailsImpl.class,
146             org.tailormap.api.security.TailormapUserDetailsImplMixin.class)
147         .addMixIn(
148             org.tailormap.api.security.TailormapOidcUser.class,
149             org.tailormap.api.security.TailormapOidcUserMixin.class)
150         .addModules(SecurityJacksonModules.getModules(this.classLoader, builder))
151         .activateDefaultTyping(builder.build(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
152         .build();
153 
154     final GenericConversionService converter = new GenericConversionService();
155     // Object -> byte[] (serialize to JSON bytes)
156     // this is not actually done in the application because sessions are
157     // read-only, so this is commented out but left here for possible future use
158     //    converter.addConverter(Object.class, byte[].class, source -> {
159     //      try {
160     //        logger.debug("Serializing Spring Session: {}", source);
161     //        return mapper.writerFor(Object.class).writeValueAsBytes(source);
162     //      } catch (JacksonException e) {
163     //        logger.error("Error serializing Spring Session object: {}", source, e);
164     //        throw new ConversionFailedException(
165     //            TypeDescriptor.forObject(source), TypeDescriptor.valueOf(byte[].class), source, e);
166     //      }
167     //    });
168     // byte[] -> Object (deserialize from JSON bytes)
169     converter.addConverter(byte[].class, Object.class, source -> {
170       try {
171         logger.debug(
172             "Deserializing Spring Session from bytes, length: {} ({})",
173             source.length,
174             new String(source, StandardCharsets.UTF_8));
175         return mapper.readValue(source, Object.class);
176       } catch (JacksonException e) {
177         String preview;
178         try {
179           String content = new String(source, StandardCharsets.UTF_8);
180           int maxLength = 256;
181           if (logger.isDebugEnabled() || logger.isTraceEnabled()) {
182             preview = content;
183           } else {
184             preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
185           }
186         } catch (Exception ex) {
187           preview = "<unavailable>";
188         }
189         logger.error(
190             "Error deserializing Spring Session from bytes, length: {}, preview: {}",
191             source.length,
192             preview,
193             e);
194         throw new ConversionFailedException(
195             TypeDescriptor.valueOf(byte[].class), TypeDescriptor.valueOf(Object.class), source, e);
196       }
197     });
198 
199     return converter;
200   }
201 }