Prechádzať zdrojové kódy

Added API 'campaignIdUnitsObservationsPOST'

Lukas Cerny 1 rok pred
rodič
commit
64a1322873

+ 5 - 5
src/main/java/cz/senslog/telemetry/database/domain/UnitTelemetry.java

@@ -11,18 +11,18 @@ public class UnitTelemetry {
     private final long unitId;
     private final OffsetDateTime timestamp;
     private final Location location;
-    private final float speed;
+    private final int speed;
     private final JsonObject observedValues;
 
-    public static UnitTelemetry of(long id, long unitId, OffsetDateTime timestamp, Location location, float speed, JsonObject observedValues) {
+    public static UnitTelemetry of(long id, long unitId, OffsetDateTime timestamp, Location location, int speed, JsonObject observedValues) {
         return new UnitTelemetry(id, unitId, timestamp, location, speed, observedValues);
     }
 
-    public static UnitTelemetry of(long unitId, OffsetDateTime timestamp, Location location, float speed, JsonObject observedValues) {
+    public static UnitTelemetry of(long unitId, OffsetDateTime timestamp, Location location, int speed, JsonObject observedValues) {
         return of(-1, unitId, timestamp, location, speed, observedValues);
     }
 
-    private UnitTelemetry(long id, long unitId, OffsetDateTime timestamp, Location location, float speed, JsonObject observedValues) {
+    private UnitTelemetry(long id, long unitId, OffsetDateTime timestamp, Location location, int speed, JsonObject observedValues) {
         this.id = id;
         this.unitId = unitId;
         this.timestamp = timestamp;
@@ -47,7 +47,7 @@ public class UnitTelemetry {
         return location;
     }
 
-    public float getSpeed() {
+    public int getSpeed() {
         return speed;
     }
 

+ 4 - 4
src/main/java/cz/senslog/telemetry/database/repository/MapLogRepository.java

@@ -727,7 +727,7 @@ public class MapLogRepository implements SensLogRepository {
                                         r.getFloat("lat"),
                                         r.getFloat("alt"),
                                         r.getFloat("angle")),
-                                r.getFloat("speed"),
+                                r.getInteger("speed"),
                                 r.getJsonObject("observed_values")
                         ))
                         .collect(toList())
@@ -814,7 +814,7 @@ public class MapLogRepository implements SensLogRepository {
                                         r.getFloat("lat"),
                                         r.getFloat("alt"),
                                         r.getFloat("angle")),
-                                r.getFloat("speed"),
+                                r.getInteger("speed"),
                                 r.getJsonObject("observed_values")))
                         .collect(toList())
                 );
@@ -900,7 +900,7 @@ public class MapLogRepository implements SensLogRepository {
                                         r.getFloat("lat"),
                                         r.getFloat("alt"),
                                         r.getFloat("angle")),
-                                r.getFloat("speed"),
+                                r.getInteger("speed"),
                                 r.getJsonObject("observed_values")))
                         .collect(toList())
                 );
@@ -987,7 +987,7 @@ public class MapLogRepository implements SensLogRepository {
                                         r.getFloat("lat"),
                                         r.getFloat("alt"),
                                         r.getFloat("angle")),
-                                r.getFloat("speed")))
+                                r.getInteger("speed")))
                         .collect(toList())
                 );
     }

+ 6 - 0
src/main/java/cz/senslog/telemetry/server/HttpVertxServer.java

@@ -11,6 +11,7 @@ import io.vertx.core.Promise;
 import io.vertx.core.http.HttpMethod;
 import io.vertx.core.json.JsonObject;
 import io.vertx.ext.web.Router;
+import io.vertx.ext.web.handler.BodyHandler;
 import io.vertx.ext.web.handler.CorsHandler;
 import io.vertx.ext.web.handler.LoggerFormat;
 import io.vertx.ext.web.handler.LoggerHandler;
@@ -39,6 +40,7 @@ public final class HttpVertxServer extends AbstractVerticle {
 
                     openAPIRouterBuilder.rootHandler(CorsHandler.create()
                             .allowedMethod(HttpMethod.GET)
+                            .allowedMethod(HttpMethod.POST)
 
                             .allowedHeader("x-requested-with")
                             .allowedHeader("Access-Control-Allow-Origin")
@@ -47,6 +49,9 @@ public final class HttpVertxServer extends AbstractVerticle {
                             .allowedHeader("Accept")
                     );
 
+                    // The order matters, so adding the body handler should happen after any PLATFORM or SECURITY_POLICY handler(s).
+                    openAPIRouterBuilder.rootHandler(BodyHandler.create());
+
 //                    openAPIRouterBuilder.securityHandler("ApiKeyAuth")
 //                            .bind(config -> APIKeyHandler.create(authProvider).header(config.getString("name")));
 
@@ -66,6 +71,7 @@ public final class HttpVertxServer extends AbstractVerticle {
                     openAPIRouterBuilder.operation("campaignIdGET").handler(apiHandler::campaignIdGET);
                     openAPIRouterBuilder.operation("campaignIdUnitsGET").handler(apiHandler::campaignIdUnitsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitsObservationsGET").handler(apiHandler::campaignIdUnitsObservationsGET);
+                    openAPIRouterBuilder.operation("campaignIdUnitsObservationsPOST").handler(apiHandler::campaignIdUnitsObservationsPOST);
                     openAPIRouterBuilder.operation("campaignIdUnitsObservationsLocationsGET").handler(apiHandler::campaignIdUnitsObservationsLocationsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitIdObservationsGET").handler(apiHandler::campaignIdUnitIdObservationsGET);
                     openAPIRouterBuilder.operation("campaignIdUnitIdLocationsGET").handler(apiHandler::campaignIdUnitIdLocationsGET);

+ 115 - 38
src/main/java/cz/senslog/telemetry/server/ws/OpenAPIHandler.java

@@ -2,7 +2,9 @@ package cz.senslog.telemetry.server.ws;
 
 import cz.senslog.telemetry.app.Application;
 import cz.senslog.telemetry.database.SortType;
+import cz.senslog.telemetry.database.domain.Location;
 import cz.senslog.telemetry.database.domain.UnitLocation;
+import cz.senslog.telemetry.database.domain.UnitTelemetry;
 import cz.senslog.telemetry.database.repository.SensLogRepository;
 import cz.senslog.telemetry.database.domain.Filter;
 import cz.senslog.telemetry.utils.TernaryCondition;
@@ -14,13 +16,13 @@ import io.vertx.ext.web.RoutingContext;
 
 import java.time.OffsetDateTime;
 import java.time.ZoneId;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
 import java.util.function.*;
 import java.util.stream.Collectors;
 
-import static cz.senslog.telemetry.utils.FluentInvoke.of;
+import static cz.senslog.telemetry.server.ws.OpenAPIHandler.ContentType.GEOJSON;
+import static cz.senslog.telemetry.server.ws.OpenAPIHandler.ContentType.JSON;
+import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
 import static java.lang.Boolean.parseBoolean;
 import static java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME;
 import static java.util.Collections.emptyList;
@@ -30,18 +32,39 @@ import static java.util.stream.Collectors.toList;
 
 public class OpenAPIHandler {
 
-    private enum ResponseFormat {
-        JSON, GEOJSON
+    protected enum ContentType {
+        JSON    ("application/json"),
+        GEOJSON ("application/geo+json")
         ;
-        public static ResponseFormat of(String format) {
+
+        private final String contentType;
+
+        ContentType(String contentType) {
+            this.contentType = contentType;
+        }
+
+        public static ContentType of(String format) {
             return valueOf(format.toUpperCase());
         }
+
+        public static ContentType ofType(String contentType) {
+            for (ContentType value : values()) {
+                if (value.contentType.equalsIgnoreCase(contentType)) {
+                    return value;
+                }
+            }
+            throw new IllegalArgumentException(String.format("No enum constant %s for the type '%s'.", ContentType.class.getName(), contentType));
+        }
+
+        public String contentType() {
+            return contentType;
+        }
     }
 
     private static final int DEFAULT_MAX_DATA_LIMIT = 500;
     private static final ZoneId DEFAULT_ZONE_ID = ZoneId.of("UTC");
     private static final boolean DEFAULT_NAVIGATION_LINKS = true;
-    private static final ResponseFormat DEFAULT_RESPONSE_FORMAT = ResponseFormat.JSON;
+    private static final ContentType DEFAULT_RESPONSE_FORMAT = JSON;
 
     private static final BiFunction<OffsetDateTime, ZoneId, String> DATE_TIME_FORMATTER = (dateTime, zoneId) ->
             OffsetDateTime.ofInstant(dateTime.toInstant(), zoneId).format(ISO_OFFSET_DATE_TIME);
@@ -182,9 +205,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
            paramsJson.put("format", paramFormat.get(0));
-           return ResponseFormat.of(paramFormat.get(0));
+           return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -214,9 +237,10 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON ->  repo.findObservationsByCampaignIdWithPaging(campaignId, from, to, zone, offset, limit, filters)
-                .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
-                        "next@NavigationLink", createNextNavLink.apply(paging.size())
-                ) : JsonObject.of()).mergeIn(JsonObject.of(
+                .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                        .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                        "next@NavigationLink", createNextNavLink.apply(paging.size())) : JsonObject.of())
+                                .mergeIn(JsonObject.of(
                         "params", paramsJson,
                         "size", paging.size(),
                         "offset", offset,
@@ -234,7 +258,7 @@ public class OpenAPIHandler {
                                 )).collect(toList())))).encode()))
                 .onFailure(rc::fail);
             case GEOJSON -> repo.findObservationsByCampaignIdWithPaging(campaignId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                             "type", "FeatureCollection",
                             "metadata", JsonObject.of(
                                     "size", paging.size(),
@@ -301,9 +325,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -334,7 +358,8 @@ public class OpenAPIHandler {
         switch (format) {
             case JSON ->
                     repo.findObservationsByCampaignIdAndUnitIdWithPaging(campaignId, unitId, from, to, zone, offset, limit, filters)
-                            .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                            .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                                    .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
                                     "next@NavigationLink", createNextNavLink.apply(paging.size())
                             ) : JsonObject.of()).mergeIn(JsonObject.of(
                                     "params", paramsJson,
@@ -354,7 +379,7 @@ public class OpenAPIHandler {
                             .onFailure(rc::fail);
             case GEOJSON ->
                     repo.findObservationsByCampaignIdAndUnitIdWithPaging(campaignId, unitId, from, to, zone, offset, limit, filters)
-                            .onSuccess(paging -> rc.response().end(JsonObject.of(
+                            .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                                     "type", "FeatureCollection",
                                     "metadata", JsonObject.of(
                                             "size", paging.size(),
@@ -419,9 +444,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -442,7 +467,7 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON ->  repo.findUnitsLocationsByCampaignId(campaignId, limitPerUnit, from, to, zone, sortType, filters)
-                    .onSuccess(locations -> rc.response().end(navLinks.mergeIn(JsonObject.of(
+                    .onSuccess(locations -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType()).end(navLinks.mergeIn(JsonObject.of(
                             "params", paramsJson,
                             "size", locations.size(),
                             "data", new JsonArray(locations.stream().map(l -> JsonObject.of(
@@ -455,7 +480,8 @@ public class OpenAPIHandler {
                             )).collect(toList())))).encode()))
                     .onFailure(rc::fail);
             case GEOJSON -> repo.findUnitsLocationsByCampaignId(campaignId, limitPerUnit, from, to, zone, sortType, filters)
-                    .onSuccess(data -> of(data.stream().collect(groupingBy(UnitLocation::getUnitId))).then(unitLocation -> rc.response().end(JsonObject.of(
+                    .onSuccess(data -> Optional.of(data.stream().collect(groupingBy(UnitLocation::getUnitId))).ifPresent(unitLocation -> rc.response()
+                            .putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                             "type", "FeatureCollection",
                             "metadata", JsonObject.of(
                                     "limitPerUnit", limitPerUnit
@@ -516,9 +542,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -549,7 +575,8 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON ->  repo.findLocationsByCampaignIdAndUnitIdWithPaging(campaignId, unitId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                            .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
                             "next@NavigationLink", createNextNavLink.apply(paging.size())
                     ) : JsonObject.of()).mergeIn(JsonObject.of(
                             "params", paramsJson,
@@ -566,7 +593,7 @@ public class OpenAPIHandler {
                                     )).collect(toList())))).encode()))
                     .onFailure(rc::fail);
             case GEOJSON -> repo.findLocationsByCampaignIdAndUnitIdWithPaging(campaignId, unitId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                                 "type", "Feature",
                                 "metadata", JsonObject.of(
                                         "size", paging.size(),
@@ -912,9 +939,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -944,7 +971,8 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON -> repo.findObservationsByCampaignIdAndUnitIdAndSensorIdWithPaging(campaignId, unitId, sensorId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                            .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
                             "next@NavigationLink", createNextNavLink.apply(paging.size())
                     ) : JsonObject.of()).mergeIn(JsonObject.of(
                             "params", paramsJson,
@@ -963,7 +991,7 @@ public class OpenAPIHandler {
                                     )).collect(toList())))).encode()))
                     .onFailure(rc::fail);
             case GEOJSON -> repo.findObservationsByCampaignIdAndUnitIdAndSensorIdWithPaging(campaignId, unitId, sensorId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                             "type", "FeatureCollection",
                             "metadata", JsonObject.of(
                                     "size", paging.size(),
@@ -1307,9 +1335,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -1339,7 +1367,8 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON -> repo.findObservationsByEventIdWithPaging(eventId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                            .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
                             "next@NavigationLink", createNextNavLink.apply(paging.size())
                     ) : JsonObject.of()).mergeIn(JsonObject.of(
                             "params", paramsJson,
@@ -1358,7 +1387,7 @@ public class OpenAPIHandler {
                                     )).collect(toList())))).encode()))
                     .onFailure(rc::fail);
             case GEOJSON -> repo.findObservationsByEventIdWithPaging(eventId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                             "type", "FeatureCollection",
                             "metadata", JsonObject.of(
                                     "size", paging.size(),
@@ -1424,9 +1453,9 @@ public class OpenAPIHandler {
         });
 
         List<String> paramFormat = rc.queryParam("format");
-        ResponseFormat format = TernaryCondition.<ResponseFormat>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
+        ContentType format = TernaryCondition.<ContentType>ternaryIf(paramFormat::isEmpty, DEFAULT_RESPONSE_FORMAT, () -> {
             paramsJson.put("format", paramFormat.get(0));
-            return ResponseFormat.of(paramFormat.get(0));
+            return ContentType.of(paramFormat.get(0));
         });
 
         List<String> paramFilters = rc.queryParam("filter");
@@ -1456,7 +1485,8 @@ public class OpenAPIHandler {
 
         switch (format) {
             case JSON -> repo.findLocationsByEventIdWithPaging(eventId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, JSON.contentType())
+                            .end(navLinks.mergeIn(navigationLinks && paging.hasNext() ? JsonObject.of(
                             "next@NavigationLink", createNextNavLink.apply(paging.size())
                     ) : JsonObject.of()).mergeIn(JsonObject.of(
                             "params", paramsJson,
@@ -1473,7 +1503,7 @@ public class OpenAPIHandler {
                                     )).collect(toList())))).encode()))
                     .onFailure(rc::fail);
             case GEOJSON -> repo.findLocationsByEventIdWithPaging(eventId, from, to, zone, offset, limit, filters)
-                    .onSuccess(paging -> rc.response().end(JsonObject.of(
+                    .onSuccess(paging -> rc.response().putHeader(CONTENT_TYPE, GEOJSON.contentType()).end(JsonObject.of(
                             "type", "Feature",
                             "metadata", JsonObject.of(
                                     "size", paging.size(),
@@ -1512,4 +1542,51 @@ public class OpenAPIHandler {
                         ))).collect(toList())).encode()))
                 .onFailure(rc::fail);
     }
+
+    public void campaignIdUnitsObservationsPOST(RoutingContext rc) {
+        switch (ContentType.ofType(rc.request().getHeader(CONTENT_TYPE))) {
+            case JSON -> Optional.of(rc.body().asJsonArray()).map(jsonArray -> jsonArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
+                    .map(f -> UnitTelemetry.of(
+                            f.getLong("unitId"),
+                            OffsetDateTime.parse(f.getString("timestamp")),
+                            Location.of(
+                                    f.getJsonObject("location").getFloat("longitude"),
+                                    f.getJsonObject("location").getFloat("latitude"),
+                                    f.getJsonObject("location").getFloat("altitude")
+                            ),
+                            f.getInteger("speed"),
+                            f.getJsonObject("observedValues")
+                    )).collect(toList()))
+                    .ifPresent(telemetries -> repo.saveAllTelemetry(telemetries)
+                    .onSuccess(count -> rc.response()
+                            .end(JsonObject.of(
+                                    "saved", count,
+                                    "errors", telemetries.size() - count
+                            ).encode()))
+                    .onFailure(rc::fail)
+            );
+
+            case GEOJSON -> Optional.of(rc.body().asJsonObject()).map(j -> j.getJsonArray("features"))
+                    .map(jsonArray -> jsonArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
+                            .map(f -> UnitTelemetry.of(
+                                f.getJsonObject("properties").getLong("unitId"),
+                                OffsetDateTime.parse(f.getJsonObject("properties").getString("timestamp")),
+                                Location.of(
+                                        f.getJsonObject("geometry").getJsonArray("coordinates").getFloat(0),
+                                        f.getJsonObject("geometry").getJsonArray("coordinates").getFloat(1),
+                                        f.getJsonObject("geometry").getJsonArray("coordinates").getFloat(2)
+                                ),
+                                f.getJsonObject("properties").getInteger("speed"),
+                                f.getJsonObject("observations")
+                            )).collect(toList()))
+                    .ifPresent(telemetries -> repo.saveAllTelemetry(telemetries)
+                            .onSuccess(count -> rc.response()
+                                    .end(JsonObject.of(
+                                            "saved", count,
+                                            "errors", telemetries.size() - count
+                                    ).encode()))
+                            .onFailure(rc::fail)
+                    );
+        }
+    }
 }

+ 106 - 3
src/main/resources/openAPISpec.yaml

@@ -114,6 +114,44 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/CampaignObservation'
+            application/geo+json:
+              schema:
+                $ref: '#/components/schemas/GeoCampaignObservation'
+        default:
+          description: unexpected error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+    post:
+      operationId: campaignIdUnitsObservationsPOST
+      parameters:
+        - $ref: '#/components/parameters/campaignIdParam'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: array
+              items:
+                $ref: '#/components/schemas/CampaignDataObservation'
+          application/geo+json:
+            schema:
+              $ref: '#/components/schemas/GeoCampaignObservation'
+      responses:
+        200:
+          description: JSON
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  saved:
+                    type: integer
+                    minimum: 0
+                  errors:
+                    type: integer
+                    minimum: 0
         default:
           description: unexpected error
           content:
@@ -664,7 +702,7 @@ paths:
           description: unexpected error
           content:
             application/json:
-              schema: 
+              schema:
                 $ref: '#/components/schemas/Error'
 
   /drivers/{driverId}/actions:
@@ -1415,8 +1453,73 @@ components:
         next@NavigationLink: "<domain>/campaigns/1/observations?offset=500"
         size: 500
         offset: 0
-        data:
-          - size: 500 TODO
+        data: []
+
+    GeoCampaignObservation:
+      type: object
+      required:
+        - type
+        - features
+      properties:
+        type:
+          type: string
+          enum:
+            - FeatureCollection
+        metadata:
+          type: object
+        features:
+          type: array
+          minLength: 1
+          items:
+            $ref: '#/components/schemas/GeoFeatureCampaign'
+
+    GeoFeatureCampaign:
+      type: object
+      required:
+        - type
+        - geometry
+        - properties
+      properties:
+        type:
+          type: string
+          enum:
+            - Feature
+        geometry:
+          $ref: '#/components/schemas/GeoPoint'
+        properties:
+          type: object
+          required:
+            - unitId
+            - timestamp
+            - speed
+          properties:
+            unitId:
+              type: integer
+              format: int64
+            timestamp:
+              type: string
+              format: date-time
+            speed:
+              type: integer
+              format: int64
+            observations:
+              type: object
+
+    GeoPoint:
+      type: object
+      properties:
+        type:
+          type: string
+          enum:
+            - Point
+        coordinates:
+          type: array
+          items:
+            type: number
+            format: float
+            minLength: 3
+            maxLength: 3
+            description: "[lon, lat, alt]"
 
     CampaignUnitObservation:
       type: object