001/*
002 * (C) Copyright 2014-2020 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 *     Kevin Leturc
019 *     Funsho David
020 */
021package org.nuxeo.runtime.mongodb;
022
023import static java.util.concurrent.TimeUnit.MILLISECONDS;
024
025import java.io.IOException;
026import java.io.InputStream;
027import java.nio.file.Files;
028import java.nio.file.Paths;
029import java.security.GeneralSecurityException;
030import java.security.KeyStore;
031import java.util.List;
032import java.util.function.Consumer;
033import java.util.stream.StreamSupport;
034
035import javax.net.ssl.SSLContext;
036
037import org.apache.commons.beanutils.BeanUtilsBean;
038import org.apache.commons.beanutils.ConvertUtilsBean;
039import org.apache.commons.beanutils.Converter;
040import org.apache.commons.beanutils.FluentPropertyBeanIntrospector;
041import org.apache.commons.beanutils.PropertyUtilsBean;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.http.ssl.SSLContextBuilder;
044import org.apache.http.ssl.SSLContexts;
045import org.apache.logging.log4j.LogManager;
046import org.apache.logging.log4j.Logger;
047import org.nuxeo.runtime.RuntimeServiceException;
048
049import com.mongodb.ConnectionString;
050import com.mongodb.MongoClientSettings;
051import com.mongodb.ReadConcern;
052import com.mongodb.ReadConcernLevel;
053import com.mongodb.ReadPreference;
054import com.mongodb.ServerAddress;
055import com.mongodb.WriteConcern;
056import com.mongodb.client.MongoClient;
057import com.mongodb.client.MongoClients;
058import com.mongodb.client.MongoDatabase;
059import com.mongodb.client.MongoIterable;
060
061/**
062 * Helper for connection to the MongoDB server
063 *
064 * @since 9.1
065 */
066public class MongoDBConnectionHelper {
067
068    private static final Logger log = LogManager.getLogger(MongoDBConnectionHelper.class);
069
070    private static final String DB_DEFAULT = "nuxeo";
071
072    private static final int MONGODB_OPTION_CONNECT_TIMEOUT_MS = 30000;
073
074    private static final int MONGODB_OPTION_READ_TIMEOUT_MS = 60000;
075
076    /** @since 11.1 */
077    public static class ReadPreferenceConverter implements Converter {
078
079        public static final ReadPreferenceConverter INSTANCE = new ReadPreferenceConverter();
080
081        @SuppressWarnings("unchecked")
082        @Override
083        public <T> T convert(Class<T> type, Object value) {
084            return (T) ReadPreference.valueOf((String) value);
085        }
086    }
087
088    /** @since 11.1 */
089    public static class ReadConcernConverter implements Converter {
090
091        public static final ReadConcernConverter INSTANCE = new ReadConcernConverter();
092
093        @SuppressWarnings("unchecked")
094        @Override
095        public <T> T convert(Class<T> type, Object value) {
096            ReadConcern readConcern;
097            if ("default".equalsIgnoreCase((String) value)) {
098                readConcern = ReadConcern.DEFAULT;
099            } else {
100                ReadConcernLevel level = ReadConcernLevel.fromString((String) value);
101                readConcern = new ReadConcern(level);
102            }
103            return (T) readConcern;
104        }
105    }
106
107    /** @since 11.1 */
108    public static class WriteConcernConverter implements Converter {
109
110        public static final WriteConcernConverter INSTANCE = new WriteConcernConverter();
111
112        @SuppressWarnings("unchecked")
113        @Override
114        public <T> T convert(Class<T> type, Object value) {
115            return (T) WriteConcern.valueOf((String) value);
116        }
117    }
118
119    private MongoDBConnectionHelper() {
120        // Empty
121    }
122
123    /**
124     * Initialize a connection to the MongoDB server
125     *
126     * @param server the server url
127     * @return the MongoDB client
128     */
129    public static MongoClient newMongoClient(String server) {
130        MongoDBConnectionConfig config = new MongoDBConnectionConfig();
131        config.server = server;
132        return newMongoClient(config);
133    }
134
135    /**
136     * Initializes a connection to the MongoDB server.
137     *
138     * @param config the MongoDB connection config
139     * @return the MongoDB client
140     * @since 10.3
141     */
142    public static MongoClient newMongoClient(MongoDBConnectionConfig config) {
143        return newMongoClient(config, null);
144    }
145
146    /**
147     * Initializes a connection to the MongoDB server.
148     *
149     * @param config the MongoDB connection config
150     * @param settingsConsumer a consumer of the client settings builder
151     * @return the MongoDB client
152     * @since 11.1
153     */
154    public static MongoClient newMongoClient(MongoDBConnectionConfig config,
155            Consumer<MongoClientSettings.Builder> settingsConsumer) {
156        String server = config.server;
157        if (StringUtils.isBlank(server)) {
158            throw new RuntimeServiceException("Missing <server> in MongoDB descriptor");
159        }
160        MongoClientSettings.Builder settingsBuilder = MongoClientSettings.builder().applicationName("Nuxeo");
161        SSLContext sslContext = getSSLContext(config);
162        if (sslContext == null) {
163            if (config.ssl != null) {
164                settingsBuilder.applyToSslSettings(s -> s.enabled(config.ssl.booleanValue()));
165            }
166        } else {
167            settingsBuilder.applyToSslSettings(s -> s.enabled(true).context(sslContext));
168        }
169
170        // don't wait forever by default when connecting
171        settingsBuilder.applyToSocketSettings(s -> s.connectTimeout(MONGODB_OPTION_CONNECT_TIMEOUT_MS, MILLISECONDS)
172                                                    .readTimeout(MONGODB_OPTION_READ_TIMEOUT_MS, MILLISECONDS));
173
174        // set properties from Nuxeo config descriptor
175        populateProperties(config, settingsBuilder);
176
177        // hook for caller to set additional properties
178        if (settingsConsumer != null) {
179            settingsConsumer.accept(settingsBuilder);
180        }
181
182        if (server.startsWith("mongodb://") || server.startsWith("mongodb+srv://")) {
183            // allow mongodb*:// URI syntax for the server, to pass everything in one string
184            settingsBuilder.applyConnectionString(new ConnectionString(server));
185        } else {
186            settingsBuilder.applyToClusterSettings(b -> b.hosts(List.of(new ServerAddress(server))));
187        }
188        MongoClientSettings settings = settingsBuilder.build();
189        MongoClient client = MongoClients.create(settings);
190        log.debug("MongoClient initialized with settings: {}", settings);
191        return client;
192    }
193
194    /**
195     * Exists to be tested.
196     *
197     * @since 11.4
198     */
199    protected static void populateProperties(MongoDBConnectionConfig config, MongoClientSettings.Builder settingsBuilder) {
200        ConvertUtilsBean convertUtils = new ConvertUtilsBean();
201        convertUtils.register(ReadPreferenceConverter.INSTANCE, ReadPreference.class);
202        convertUtils.register(ReadConcernConverter.INSTANCE, ReadConcern.class);
203        convertUtils.register(WriteConcernConverter.INSTANCE, WriteConcern.class);
204        PropertyUtilsBean propertyUtils = new PropertyUtilsBean();
205        propertyUtils.addBeanIntrospector(new FluentPropertyBeanIntrospector(""));
206        BeanUtilsBean beanUtils = new BeanUtilsBean(convertUtils, propertyUtils);
207        try {
208            beanUtils.populate(settingsBuilder, config.properties);
209        } catch (ReflectiveOperationException e) {
210            throw new RuntimeServiceException(e);
211        }
212    }
213
214    protected static SSLContext getSSLContext(MongoDBConnectionConfig config) {
215        try {
216            KeyStore trustStore = loadKeyStore(config.trustStorePath, config.trustStorePassword, config.trustStoreType);
217            KeyStore keyStore = loadKeyStore(config.keyStorePath, config.keyStorePassword, config.keyStoreType);
218            if (trustStore == null && keyStore == null) {
219                return null;
220            }
221            SSLContextBuilder sslContextBuilder = SSLContexts.custom();
222            if (trustStore != null) {
223                sslContextBuilder.loadTrustMaterial(trustStore, null);
224            }
225            if (keyStore != null) {
226                sslContextBuilder.loadKeyMaterial(keyStore,
227                        StringUtils.isBlank(config.keyStorePassword) ? null : config.keyStorePassword.toCharArray());
228            }
229            return sslContextBuilder.build();
230        } catch (GeneralSecurityException | IOException e) {
231            throw new RuntimeServiceException("Cannot setup SSL context: " + config, e);
232        }
233    }
234
235    protected static KeyStore loadKeyStore(String path, String password, String type)
236            throws GeneralSecurityException, IOException {
237        if (StringUtils.isBlank(path)) {
238            return null;
239        }
240        String keyStoreType = StringUtils.defaultIfBlank(type, KeyStore.getDefaultType());
241        KeyStore keyStore = KeyStore.getInstance(keyStoreType);
242        char[] passwordChars = StringUtils.isBlank(password) ? null : password.toCharArray();
243        try (InputStream is = Files.newInputStream(Paths.get(path))) {
244            keyStore.load(is, passwordChars);
245        }
246        return keyStore;
247    }
248
249    /**
250     * @return a database representing the specified database
251     */
252    public static MongoDatabase getDatabase(MongoClient mongoClient, String dbname) {
253        if (StringUtils.isBlank(dbname)) {
254            dbname = DB_DEFAULT;
255        }
256        return mongoClient.getDatabase(dbname);
257    }
258
259    /**
260     * Check if the collection exists and if it is not empty
261     *
262     * @param mongoDatabase the Mongo database
263     * @param collection the collection name
264     * @return true if the collection exists and not empty, false otherwise
265     */
266    public static boolean hasCollection(MongoDatabase mongoDatabase, String collection) {
267        MongoIterable<String> collections = mongoDatabase.listCollectionNames();
268        boolean found = StreamSupport.stream(collections.spliterator(), false).anyMatch(collection::equals);
269        return found && mongoDatabase.getCollection(collection).estimatedDocumentCount() > 0;
270    }
271}