diff --git a/.gitignore b/.gitignore index 65c6b6c..2a56622 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.osm *.pbf -*.json +!*.json *.geojson map.osm map.csv @@ -18,4 +18,9 @@ cmd/horizon/horizon cmd/horizon/horizon.exe cmd/horizon/*.zip cmd/horizon/*.tar.gz -.build.sh \ No newline at end of file +.build.sh +.swag-gen.sh +docs/* +rest/docs/docs.go +rest/docs/swagger.json +rest/docs/swagger.yaml \ No newline at end of file diff --git a/README.md b/README.md index 42511c8..5194bf3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Horizon v0.5.0 [![GoDoc](https://godoc.org/github.com/LdDl/horizon?status.svg)](https://godoc.org/github.com/LdDl/horizon) [![Build Status](https://travis-ci.com/LdDl/horizon.svg?branch=master)](https://travis-ci.com/LdDl/horizon) [![Sourcegraph](https://sourcegraph.com/github.com/LdDl/horizon/-/badge.svg)](https://sourcegraph.com/github.com/LdDl/horizon?badge) [![Go Report Card](https://goreportcard.com/badge/github.com/LdDl/horizon)](https://goreportcard.com/report/github.com/LdDl/horizon) [![GitHub tag](https://img.shields.io/github/tag/LdDl/horizon.svg)](https://github.com/LdDl/horizon/releases) +# Horizon v0.5.1 [![GoDoc](https://godoc.org/github.com/LdDl/horizon?status.svg)](https://godoc.org/github.com/LdDl/horizon) [![Build Status](https://travis-ci.com/LdDl/horizon.svg?branch=master)](https://travis-ci.com/LdDl/horizon) [![Sourcegraph](https://sourcegraph.com/github.com/LdDl/horizon/-/badge.svg)](https://sourcegraph.com/github.com/LdDl/horizon?badge) [![Go Report Card](https://goreportcard.com/badge/github.com/LdDl/horizon)](https://goreportcard.com/report/github.com/LdDl/horizon) [![GitHub tag](https://img.shields.io/github/tag/LdDl/horizon.svg)](https://github.com/LdDl/horizon/releases) # Work in progress Horizon is project aimed to do map matching (snap GPS data to map) and routing (find shortest path between two points) @@ -22,12 +22,12 @@ Horizon is targeted to make map matching as [OSRM](https://github.com/Project-OS Via _go get_: ```shell go get github.com/LdDl/horizon -go install github.com/LdDl/horizon/cmd/horizon@v0.5.0 +go install github.com/LdDl/horizon/cmd/horizon@v0.5.1 ``` Via downloading prebuilt binary and making updates in yours PATH environment varibale (both Linux and Windows): -* Windows - https://github.com/LdDl/horizon/releases/download/v0.5.0/windows-horizon.zip -* Linux - https://github.com/LdDl/horizon/releases/download/v0.5.0/linux-amd64-horizon.tar.gz +* Windows - https://github.com/LdDl/horizon/releases/download/v0.5.1/windows-horizon.zip +* Linux - https://github.com/LdDl/horizon/releases/download/v0.5.1/linux-amd64-horizon.tar.gz Check if **horizon** binary was installed properly: ```shell @@ -120,6 +120,12 @@ Instruction has been made for Linux mainly. For Windows or OSX the way may vary. +7. There is also [Swagger](https://en.wikipedia.org/wiki/Swagger_(software)) documentation for inialized REST API. + + If you use http://localhost:32800/ then you can navigate to http://localhost:32800/api/v0.1.0/docs#overview for API documentation. It may look like (thanks [rapidoc](https://github.com/mrin9/RapiDoc#rapidoc)): + + + ## Benchmark Please follow [link](BENCHMARK.md) @@ -147,6 +153,7 @@ Thanks for approach described in this paper: * Fiber framework (used for server app) - [Fiber](https://github.com/gofiber/fiber). License is MIT * MapboxGL for Front-end - [mapboxgl](https://github.com/mapbox/mapbox-gl-js). License is 3-Clause BSD license * moments.js for Front-end - [moment.js](https://github.com/moment/moment/). License is MIT +* rapidoc for [swagger](https://en.wikipedia.org/wiki/Swagger_(software)) visualization - [rapidoc](https://github.com/mrin9/RapiDoc/blob/master/LICENSE.txt). License is MIT ## License You can check it [here](https://github.com/LdDl/horizon/blob/master/LICENSE) diff --git a/ROADMAP.md b/ROADMAP.md index 6d5ef46..3eb3ffe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,6 +20,7 @@ New ideas, thought about needed features will be store in this file. * More screenshots in README * Migrate to Fiber v2 * Migrate to new version of CH (https://github.com/LdDl/ch) v1.7.5 +* Swagger docs (autogen) - https://github.com/LdDl/horizon/pull/10 ### W.I.P * gRPC server side @@ -28,8 +29,6 @@ New ideas, thought about needed features will be store in this file. * Isochrones service * gRPC docs (autogen) -* Swagger docs (autogen) - https://github.com/LdDl/horizon/pull/10 - * Snake case for JSON's * Stabilization of core (need many tests as possible) diff --git a/cmd/horizon/main.go b/cmd/horizon/main.go index 567c14f..d0f4a7f 100644 --- a/cmd/horizon/main.go +++ b/cmd/horizon/main.go @@ -8,6 +8,7 @@ import ( "github.com/LdDl/horizon" "github.com/LdDl/horizon/rest" + "github.com/LdDl/horizon/rest/docs" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/valyala/fasthttp" @@ -26,6 +27,16 @@ var ( apiVersion = "0.1.0" ) +// @title API for working with Horizon +// @version 0.1.0 + +// @contact.name API support +// @contact.url https://github.com/LdDl/horizon#table-of-contents +// @contact.email sexykdi@gmail.com + +// @BasePath / + +// @schemes http https func main() { flag.Parse() @@ -43,7 +54,7 @@ func main() { config := fiber.Config{ DisableStartupMessage: false, ErrorHandler: func(ctx *fiber.Ctx, err error) error { - log.Println(err) + log.Println("error:", err) return ctx.Status(fasthttp.StatusInternalServerError).JSON(map[string]string{"Error": "undefined"}) }, IdleTimeout: 10 * time.Second, @@ -68,6 +79,12 @@ func main() { apiVersionGroup.Post("/shortest", rest.FindSP(matcher)) apiVersionGroup.Post("/isochrones", rest.FindIsochrones(matcher)) + docsStaticGroup := apiVersionGroup.Group("/docs-static") + docsStaticGroup.Use("/", docs.PrepareStaticAssets()) + + docsGroup := apiVersionGroup.Group("/docs") + docsGroup.Use("/", docs.PrepareStaticPage()) + // Start server if err := server.Listen(fmt.Sprintf("%s:%d", *addrFlag, *portFlag)); err != nil { fmt.Println(err) diff --git a/errors.go b/errors.go index 2b88c47..6ede109 100644 --- a/errors.go +++ b/errors.go @@ -13,4 +13,5 @@ var ( ErrTargetNotFound = fmt.Errorf("can't find closest edge for 'target' point") ErrTargetHasMoreEdges = fmt.Errorf("more than 1 edge for 'target' point") ErrPathNotFound = fmt.Errorf("path not found") + ErrSameVertex = fmt.Errorf("same vertex") ) diff --git a/go.mod b/go.mod index 4442d61..0e13551 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/LdDl/horizon -go 1.13 +go 1.16 require ( github.com/LdDl/ch v1.7.7 diff --git a/images/swagger1.png b/images/swagger1.png new file mode 100644 index 0000000..1d7d26c Binary files /dev/null and b/images/swagger1.png differ diff --git a/map_matcher_simple_sp.go b/map_matcher_simple_sp.go index 6bb451a..f992719 100644 --- a/map_matcher_simple_sp.go +++ b/map_matcher_simple_sp.go @@ -67,6 +67,9 @@ func (matcher *MapMatcher) FindShortestPath(source, target *GPSMeasurement, stat if ans == -1.0 { return MatcherResult{}, ErrPathNotFound } + if len(path) < 2 { + return MatcherResult{}, ErrSameVertex + } edges := []Edge{} result := MatcherResult{ Observations: make([]*ObservationResult, 2), diff --git a/rest/codes/codes.go b/rest/codes/codes.go new file mode 100644 index 0000000..d9ecbc8 --- /dev/null +++ b/rest/codes/codes.go @@ -0,0 +1,78 @@ +package codes + +// Success200 OK +// swagger:model +type Success200 struct { + // Code text + Status string `json:"Status" example:"OK"` +} + +// Success201 Created +// swagger:model +type Success201 struct { + // Code text + Status string `json:"Status" example:"Created"` +} + +// Error500 Internal Server Error +// swagger:model +type Error500 struct { + // Error text + Error string `json:"Error" example:"Internal Server Error"` +} + +// Error502 Bad Gateway +// swagger:model +type Error502 struct { + // Error text + Error string `json:"Error" example:"Bad Gateway"` +} + +// Error503 Service Unavailable +// swagger:model +type Error503 struct { + // Error text + Error string `json:"Error" example:"Service Unavailable"` +} + +// Error400 Internal Server Error +// swagger:model +type Error400 struct { + // Error text + Error string `json:"Error" example:"Internal Server Error"` +} + +// Error401 Unauthorized +// swagger:model +type Error401 struct { + // Error text + Error string `json:"Error" example:"Unauthorized"` +} + +// Error403 Forbidden +// swagger:model +type Error403 struct { + // Error text + Error string `json:"Error" example:"Forbidden"` +} + +// Error409 Conflict +// swagger:model +type Error409 struct { + // Error text + Error string `json:"Error" example:"Conflict"` +} + +// Error424 Failed Dependency +// swagger:model +type Error424 struct { + // Error text + Error string `json:"Error" example:"Failed Dependency"` +} + +// Error422 Unprocessable Entity +// swagger:model +type Error422 struct { + // Error text + Error string `json:"Error" example:"Unprocessable Entity"` +} diff --git a/rest/docs/assets/swagger.json b/rest/docs/assets/swagger.json new file mode 100644 index 0000000..211b449 --- /dev/null +++ b/rest/docs/assets/swagger.json @@ -0,0 +1,314 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "title": "API for working with Horizon", + "contact": { + "name": "API support", + "url": "https://github.com/LdDl/horizon#table-of-contents", + "email": "sexykdi@gmail.com" + }, + "version": "0.1.0" + }, + "basePath": "/", + "paths": { + "/api/v0.1.0/isochrones": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Isochrones" + ], + "summary": "Find possible isochrones via POST-request", + "parameters": [ + { + "description": "Example of request", + "name": "POST-body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.IsochronesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/rest.IsochronesResponse" + } + }, + "424": { + "description": "Failed Dependency", + "schema": { + "$ref": "#/definitions/codes.Error424" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/codes.Error500" + } + } + } + } + }, + "/api/v0.1.0/mapmatch": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Map matching" + ], + "summary": "Do map match via POST-request", + "parameters": [ + { + "description": "Example of request", + "name": "POST-body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.MapMatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/rest.MapMatchResponse" + } + }, + "424": { + "description": "Failed Dependency", + "schema": { + "$ref": "#/definitions/codes.Error424" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/codes.Error500" + } + } + } + } + }, + "/api/v0.1.0/shortest": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Routing" + ], + "summary": "Find shortest path via POST-request", + "parameters": [ + { + "description": "Example of request", + "name": "POST-body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.SPRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/rest.SPResponse" + } + }, + "424": { + "description": "Failed Dependency", + "schema": { + "$ref": "#/definitions/codes.Error424" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/codes.Error500" + } + } + } + } + } + }, + "definitions": { + "codes.Error424": { + "type": "object", + "properties": { + "Error": { + "description": "Error text", + "type": "string", + "example": "Failed Dependency" + } + } + }, + "codes.Error500": { + "type": "object", + "properties": { + "Error": { + "description": "Error text", + "type": "string", + "example": "Internal Server Error" + } + } + }, + "rest.GPSToMapMatch": { + "type": "object", + "properties": { + "lonLat": { + "description": "[Longitude, Latitude]", + "type": "array", + "items": { + "type": "number" + }, + "example": [ + 37.601249363208915, + 55.745374309126895 + ] + }, + "tm": { + "description": "Timestamp. Field would be ignored for request on '/shortest' service.", + "type": "string", + "example": "2020-03-11T00:00:00" + } + } + }, + "rest.GPSToShortestPath": { + "type": "object", + "properties": { + "lonLat": { + "description": "[Longitude, Latitude]", + "type": "array", + "items": { + "type": "number" + }, + "example": [ + 37.601249363208915, + 55.745374309126895 + ] + } + } + }, + "rest.IsochronesRequest": { + "type": "object", + "properties": { + "lonLat": { + "description": "[Longitude, Latitude]", + "type": "array", + "items": { + "type": "number" + }, + "example": [ + 37.601249363208915, + 55.745374309126895 + ] + }, + "maxCost": { + "description": "Max cost restrictions for single isochrone. Should be in range [0,+Inf]. Minumim is 0.", + "type": "number", + "example": 2100 + }, + "nearestRadius": { + "description": "Max radius of search for nearest vertex (Optional, default is 25.0, should be in range [0,+Inf])", + "type": "number", + "example": 25 + } + } + }, + "rest.IsochronesResponse": { + "type": "object", + "properties": { + "warnings": { + "description": "Warnings", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "Warning" + ] + } + } + }, + "rest.MapMatchRequest": { + "type": "object", + "properties": { + "gps": { + "description": "Set of GPS data", + "type": "array", + "items": { + "$ref": "#/definitions/rest.GPSToMapMatch" + } + }, + "maxStates": { + "description": "Max number of states for single GPS point (in range [1, 10], default is 5). Field would be ignored for request on '/shortest' service.", + "type": "integer", + "example": 5 + }, + "stateRadius": { + "description": "Max radius of search for potential candidates (in range [7, 50], default is 25.0)", + "type": "number", + "example": 7 + } + } + }, + "rest.MapMatchResponse": { + "type": "object", + "properties": { + "warnings": { + "description": "Warnings", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "Warning" + ] + } + } + }, + "rest.SPRequest": { + "type": "object", + "properties": { + "gps": { + "description": "Set of GPS data", + "type": "array", + "items": { + "$ref": "#/definitions/rest.GPSToShortestPath" + } + }, + "stateRadius": { + "description": "Max radius of search for potential candidates (in range [7, 50], default is 25.0)", + "type": "number", + "example": 10 + } + } + }, + "rest.SPResponse": { + "type": "object", + "properties": { + "warnings": { + "description": "Warnings", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "Warning" + ] + } + } + } + } +} \ No newline at end of file diff --git a/rest/docs/index.html b/rest/docs/index.html new file mode 100644 index 0000000..1fbd7e1 --- /dev/null +++ b/rest/docs/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/rest/docs/server.go b/rest/docs/server.go new file mode 100644 index 0000000..4bb426f --- /dev/null +++ b/rest/docs/server.go @@ -0,0 +1,31 @@ +package docs + +import ( + "embed" + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" +) + +// Embed a single file +//go:embed index.html +var f embed.FS + +func PrepareStaticPage() func(*fiber.Ctx) error { + return filesystem.New(filesystem.Config{ + Root: http.FS(f), + }) +} + +// Embed a directory +//go:embed assets/* +var embedDirStatic embed.FS + +func PrepareStaticAssets() func(*fiber.Ctx) error { + return filesystem.New(filesystem.Config{ + Root: http.FS(embedDirStatic), + PathPrefix: "", + Browse: false, + }) +} diff --git a/rest/isochrones.go b/rest/isochrones.go index 679d385..403dca1 100644 --- a/rest/isochrones.go +++ b/rest/isochrones.go @@ -10,23 +10,33 @@ import ( ) // IsochronesRequest User's request for isochrones +// swagger:model type IsochronesRequest struct { // [Longitude, Latitude] - LonLat [2]float64 `json:"lonLat"` + LonLat [2]float64 `json:"lonLat" example:"37.601249363208915,55.745374309126895"` // Max cost restrictions for single isochrone. Should be in range [0,+Inf]. Minumim is 0. - MaxCost *float64 `json:"maxCost"` + MaxCost *float64 `json:"maxCost" example:"2100.0"` // Max radius of search for nearest vertex (Optional, default is 25.0, should be in range [0,+Inf]) - MaxNearestRadius *float64 `json:"nearestRadius"` + MaxNearestRadius *float64 `json:"nearestRadius" example:"25.0"` } // IsochronesResponse Server's response for isochrones request +// swagger:model type IsochronesResponse struct { - Isochrones *geojson.FeatureCollection `json:"data"` + Isochrones *geojson.FeatureCollection `json:"data" swaggerignore:"true"` // Warnings - Warnings []string `json:"warnings"` + Warnings []string `json:"warnings" example:"Warning"` } // FindIsochrones Find possible isochrones via POST-request +// @Summary Find possible isochrones via POST-request +// @Tags Isochrones +// @Produce json +// @Param POST-body body rest.IsochronesRequest true "Example of request" +// @Success 200 {object} rest.IsochronesResponse +// @Failure 424 {object} codes.Error424 +// @Failure 500 {object} codes.Error500 +// @Router /api/v0.1.0/isochrones [POST] func FindIsochrones(matcher *horizon.MapMatcher) func(*fiber.Ctx) error { fn := func(ctx *fiber.Ctx) error { bodyBytes := ctx.Context().PostBody() diff --git a/rest/map_match.go b/rest/map_match.go index 88571a3..fea5bd4 100644 --- a/rest/map_match.go +++ b/rest/map_match.go @@ -15,31 +15,43 @@ var ( ) // MapMatchRequest User's request for map matching +// swagger:model type MapMatchRequest struct { // Set of GPS data Data []GPSToMapMatch `json:"gps"` // Max number of states for single GPS point (in range [1, 10], default is 5). Field would be ignored for request on '/shortest' service. - MaxStates *int `json:"maxStates"` + MaxStates *int `json:"maxStates" example:"5"` // Max radius of search for potential candidates (in range [7, 50], default is 25.0) - StateRadius *float64 `json:"stateRadius"` + StateRadius *float64 `json:"stateRadius" example:"7.0"` } // GPSToMapMatch Representation of GPS data +// swagger:model type GPSToMapMatch struct { // Timestamp. Field would be ignored for request on '/shortest' service. - Timestamp string `json:"tm"` + Timestamp string `json:"tm" example:"2020-03-11T00:00:00"` // [Longitude, Latitude] - LonLat [2]float64 `json:"lonLat"` + LonLat [2]float64 `json:"lonLat" example:"37.601249363208915,55.745374309126895"` } // MapMatchResponse Server's response for map matching request +// swagger:model type MapMatchResponse struct { - Path *geojson.FeatureCollection `json:"data"` + // GeoJSON Data + Path *geojson.FeatureCollection `json:"data" swaggerignore:"true"` // Warnings - Warnings []string `json:"warnings"` + Warnings []string `json:"warnings" example:"Warning"` } // MapMatch Do map match via POST-request +// @Summary Do map match via POST-request +// @Tags Map matching +// @Produce json +// @Param POST-body body rest.MapMatchRequest true "Example of request" +// @Success 200 {object} rest.MapMatchResponse +// @Failure 424 {object} codes.Error424 +// @Failure 500 {object} codes.Error500 +// @Router /api/v0.1.0/mapmatch [POST] func MapMatch(matcher *horizon.MapMatcher) func(*fiber.Ctx) error { fn := func(ctx *fiber.Ctx) error { diff --git a/rest/shortest_path.go b/rest/shortest_path.go index 332a8ce..8eae2b8 100644 --- a/rest/shortest_path.go +++ b/rest/shortest_path.go @@ -11,26 +11,27 @@ import ( ) // SPRequest User's request for finding shortest path +// swagger:model type SPRequest struct { // Set of GPS data Data []GPSToShortestPath `json:"gps"` // Max radius of search for potential candidates (in range [7, 50], default is 25.0) - StateRadius *float64 `json:"stateRadius"` + StateRadius *float64 `json:"stateRadius" example:"10.0"` } // GPSToShortestPath Representation of GPS data +// swagger:model type GPSToShortestPath struct { - // Timestamp. Field would be ignored for request on '/shortest' service. - Timestamp string `json:"tm"` // [Longitude, Latitude] - LonLat [2]float64 `json:"lonLat"` + LonLat [2]float64 `json:"lonLat" example:"37.601249363208915,55.745374309126895"` } // SPResponse Server's response for shortest path request +// swagger:model type SPResponse struct { - Path *geojson.FeatureCollection `json:"data"` + Path *geojson.FeatureCollection `json:"data" swaggerignore:"true"` // Warnings - Warnings []string `json:"warnings"` + Warnings []string `json:"warnings" example:"Warning"` } // FindSP Find shortest path via POST-request @@ -38,6 +39,14 @@ type SPResponse struct { Actually it can be done just by doing MapMatch for 2 proided points, but this just proof of concept Services takes two points, snaps those to nearest vertices and finding path via Dijkstra's algorithm. Output is familiar to MapMatch() */ +// @Summary Find shortest path via POST-request +// @Tags Routing +// @Produce json +// @Param POST-body body rest.SPRequest true "Example of request" +// @Success 200 {object} rest.SPResponse +// @Failure 424 {object} codes.Error424 +// @Failure 500 {object} codes.Error500 +// @Router /api/v0.1.0/shortest [POST] func FindSP(matcher *horizon.MapMatcher) func(*fiber.Ctx) error { fn := func(ctx *fiber.Ctx) error { bodyBytes := ctx.Context().PostBody()