View Javadoc
1   /*
2    * Copyright (C) 2024 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller.admin;
7   
8   import com.fasterxml.jackson.databind.ObjectMapper;
9   import io.micrometer.core.annotation.Timed;
10  import io.swagger.v3.oas.annotations.Operation;
11  import io.swagger.v3.oas.annotations.media.Content;
12  import io.swagger.v3.oas.annotations.media.Schema;
13  import io.swagger.v3.oas.annotations.responses.ApiResponse;
14  import java.io.IOException;
15  import java.lang.invoke.MethodHandles;
16  import java.util.NoSuchElementException;
17  import org.apache.solr.client.solrj.SolrClient;
18  import org.apache.solr.client.solrj.SolrServerException;
19  import org.apache.solr.client.solrj.response.SolrPingResponse;
20  import org.apache.solr.common.SolrException;
21  import org.slf4j.Logger;
22  import org.slf4j.LoggerFactory;
23  import org.springframework.http.HttpStatus;
24  import org.springframework.http.MediaType;
25  import org.springframework.http.ResponseEntity;
26  import org.springframework.transaction.annotation.Transactional;
27  import org.springframework.web.bind.annotation.DeleteMapping;
28  import org.springframework.web.bind.annotation.GetMapping;
29  import org.springframework.web.bind.annotation.PathVariable;
30  import org.springframework.web.bind.annotation.PutMapping;
31  import org.springframework.web.bind.annotation.RestController;
32  import org.springframework.web.server.ResponseStatusException;
33  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
34  import org.tailormap.api.persistence.SearchIndex;
35  import org.tailormap.api.persistence.TMFeatureSource;
36  import org.tailormap.api.persistence.TMFeatureType;
37  import org.tailormap.api.repository.FeatureTypeRepository;
38  import org.tailormap.api.repository.SearchIndexRepository;
39  import org.tailormap.api.solr.SolrHelper;
40  import org.tailormap.api.solr.SolrService;
41  import org.tailormap.api.viewer.model.ErrorResponse;
42  
43  /** Admin controller for Solr. */
44  @RestController
45  public class SolrAdminController {
46    private static final Logger logger =
47        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
48    private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
49  
50    private final FeatureTypeRepository featureTypeRepository;
51    private final SearchIndexRepository searchIndexRepository;
52    private final SolrService solrService;
53  
54    public SolrAdminController(
55        FeatureSourceFactoryHelper featureSourceFactoryHelper,
56        FeatureTypeRepository featureTypeRepository,
57        SearchIndexRepository searchIndexRepository,
58        SolrService solrService) {
59      this.featureSourceFactoryHelper = featureSourceFactoryHelper;
60      this.featureTypeRepository = featureTypeRepository;
61      this.searchIndexRepository = searchIndexRepository;
62      this.solrService = solrService;
63    }
64  
65    /**
66     * Ping solr.
67     *
68     * @return the response entity (ok or an error response)
69     */
70    @Operation(summary = "Ping Solr", description = "Ping Solr to check if it is available")
71    @ApiResponse(
72        responseCode = "200",
73        description = "Solr is available",
74        content =
75            @Content(
76                mediaType = MediaType.APPLICATION_JSON_VALUE,
77                schema = @Schema(example = "{\"status\":\"OK\",\"timeElapsed\":1}")))
78    @ApiResponse(
79        responseCode = "500",
80        description = "Solr is not available",
81        content =
82            @Content(
83                mediaType = MediaType.APPLICATION_JSON_VALUE,
84                schema = @Schema(example = "{\"message\":\"Some error message..\",\"code\":500}")))
85    @GetMapping(
86        path = "${tailormap-api.admin.base-path}/index/ping",
87        produces = MediaType.APPLICATION_JSON_VALUE)
88    public ResponseEntity<?> pingSolr() {
89      try (SolrClient solrClient = solrService.getSolrClientForSearching()) {
90        final SolrPingResponse ping = solrClient.ping();
91        logger.info("Solr ping status {}", ping.getResponse().get("status"));
92        return ResponseEntity.ok(
93            new ObjectMapper()
94                .createObjectNode()
95                .put("status", ping.getResponse().get("status").toString())
96                .put("timeElapsed", ping.getElapsedTime()));
97      } catch (IOException | SolrServerException e) {
98        logger.error("Error pinging solr", e);
99        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
100           .contentType(MediaType.APPLICATION_JSON)
101           .body(
102               new ErrorResponse()
103                   .message(e.getLocalizedMessage())
104                   .code(HttpStatus.INTERNAL_SERVER_ERROR.value()));
105     }
106   }
107 
108   /**
109    * (re-) Index a layer.
110    *
111    * @param searchIndexId the searchIndex id
112    * @return the response entity (accepted or an error response)
113    */
114   @Operation(
115       summary = "Create or update a feature type index",
116       description =
117           "Create or update a feature type index for a layer, will erase existing index if present")
118   @ApiResponse(responseCode = "202", description = "Index create or update request accepted")
119   @ApiResponse(
120       responseCode = "404",
121       description = "Layer does not have feature type",
122       content =
123           @Content(
124               mediaType = MediaType.APPLICATION_JSON_VALUE,
125               schema =
126                   @Schema(
127                       example = "{\"message\":\"Layer does not have feature type\",\"code\":404}")))
128   @ApiResponse(
129       responseCode = "404",
130       description = "Indexing WFS feature types is not supported",
131       content =
132           @Content(
133               mediaType = MediaType.APPLICATION_JSON_VALUE,
134               schema =
135                   @Schema(
136                       example =
137                           "{\"message\":\"Layer does not have valid feature type for indexing\",\"code\":400}")))
138   @ApiResponse(
139       responseCode = "500",
140       description = "Error while indexing",
141       content =
142           @Content(
143               mediaType = MediaType.APPLICATION_JSON_VALUE,
144               schema = @Schema(example = "{\"message\":\"Some error message..\",\"code\":500}")))
145   @Transactional
146   @Timed(value = "index_feature_type", description = "time spent to index feature type")
147   @PutMapping(
148       path = "${tailormap-api.admin.base-path}/index/{searchIndexId}",
149       produces = MediaType.APPLICATION_JSON_VALUE)
150   public ResponseEntity<?> index(@PathVariable Long searchIndexId) {
151 
152     SearchIndex searchIndex =
153         searchIndexRepository
154             .findById(searchIndexId)
155             .orElseThrow(
156                 () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Search index not found"));
157 
158     TMFeatureType indexingFT =
159         featureTypeRepository
160             .findById(searchIndex.getFeatureTypeId())
161             .orElseThrow(
162                 () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Feature type not found"));
163 
164     if (TMFeatureSource.Protocol.WFS.equals(indexingFT.getFeatureSource().getProtocol())) {
165       // the search index should not exist for WFS feature types, but just in case
166       searchIndex.setStatus(SearchIndex.Status.ERROR).setComment("WFS indexing not supported");
167       throw new ResponseStatusException(
168           HttpStatus.BAD_REQUEST, "Layer does not have valid feature type for indexing");
169     }
170 
171     boolean createNewIndex =
172         (null == searchIndex.getLastIndexed()
173             || searchIndex.getStatus() == SearchIndex.Status.INITIAL);
174     try (SolrClient solrClient = solrService.getSolrClientForIndexing();
175         SolrHelper solrHelper = new SolrHelper(solrClient)) {
176       solrHelper.addFeatureTypeIndex(searchIndex, indexingFT, featureSourceFactoryHelper);
177       searchIndexRepository.save(searchIndex);
178     } catch (UnsupportedOperationException | IOException | SolrServerException | SolrException e) {
179       logger.error("Error indexing", e);
180       searchIndex.setStatus(SearchIndex.Status.ERROR);
181       searchIndexRepository.save(searchIndex);
182       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
183           .contentType(MediaType.APPLICATION_JSON)
184           .body(
185               new ErrorResponse()
186                   .message(e.getLocalizedMessage())
187                   .code(HttpStatus.INTERNAL_SERVER_ERROR.value()));
188     }
189 
190     if (createNewIndex) {
191       logger.info("Created new index for search index {}", searchIndexId);
192       return ResponseEntity.status(HttpStatus.CREATED).build();
193     } else {
194       logger.info("Updated index for search index {}", searchIndexId);
195       return ResponseEntity.accepted().build();
196     }
197   }
198 
199   /**
200    * Clear an index; does not remove the {@link SearchIndex} metadata.
201    *
202    * @param searchIndexId the searchindex id
203    * @return the response entity ({@code 204 NOCONTENT} or an error response)
204    */
205   @Operation(
206       summary = "Clear index for a feature type",
207       description = "Clear index for the feature type")
208   @ApiResponse(responseCode = "204", description = "Index cleared")
209   @ApiResponse(responseCode = "404", description = "Index not configured for feature type")
210   @ApiResponse(
211       responseCode = "500",
212       description = "Error while clearing index",
213       content =
214           @Content(
215               mediaType = MediaType.APPLICATION_JSON_VALUE,
216               schema = @Schema(example = "{\"message\":\"Some error message..\",\"code\":500}")))
217   @Timed(value = "index_delete", description = "time spent to delete an index of a feature type")
218   @DeleteMapping(
219       path = "${tailormap-api.admin.base-path}/index/{searchIndexId}",
220       produces = MediaType.APPLICATION_JSON_VALUE)
221   @Transactional
222   public ResponseEntity<?> clearIndex(@PathVariable Long searchIndexId) {
223     try (SolrClient solrClient = solrService.getSolrClientForSearching();
224         SolrHelper solrHelper = new SolrHelper(solrClient)) {
225       solrHelper.clearIndexForLayer(searchIndexId);
226       // do not delete the SearchIndex metadata object
227       // searchIndexRepository.findById(searchIndexId).ifPresent(searchIndexRepository::delete);
228       SearchIndex searchIndex =
229           searchIndexRepository
230               .findById(searchIndexId)
231               .orElseThrow(
232                   () ->
233                       new ResponseStatusException(HttpStatus.NOT_FOUND, "Search index not found"));
234       searchIndex
235           .setLastIndexed(null)
236           .setStatus(SearchIndex.Status.INITIAL)
237           .setComment("Index cleared");
238       searchIndexRepository.save(searchIndex);
239     } catch (IOException | SolrServerException | NoSuchElementException e) {
240       logger.warn("Error clearing index", e);
241       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
242           .contentType(MediaType.APPLICATION_JSON)
243           .body(
244               new ErrorResponse()
245                   .message(e.getLocalizedMessage())
246                   .code(HttpStatus.INTERNAL_SERVER_ERROR.value()));
247     }
248 
249     logger.info("Index cleared for index {}", searchIndexId);
250     return ResponseEntity.noContent().build();
251   }
252 }