From c711a4da718975cf2ed1a0841f10b667df845b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Morin?= Date: Sat, 25 Nov 2023 12:01:16 -0500 Subject: [PATCH 1/7] Fmorin/part4 (#1) Part4 - Create, Replace, Delete --- API.md | 47 +++++- FEATURES.md | 9 +- README.md | 4 + hugo/content/usage/_index.md | 1 + .../usage/create_replace_delete_feature.md | 49 +++++++ internal/api/api.go | 2 + internal/api/openapi.go | 88 +++++++++++- internal/data/catalog.go | 23 +++ internal/data/catalog_db.go | 54 +++++++ internal/data/catalog_mock.go | 12 ++ internal/data/db_sql.go | 86 ++++++++++- internal/service/handler.go | 136 ++++++++++++------ 12 files changed, 457 insertions(+), 54 deletions(-) create mode 100644 hugo/content/usage/create_replace_delete_feature.md diff --git a/API.md b/API.md index 937720e0..c140e589 100644 --- a/API.md +++ b/API.md @@ -5,6 +5,7 @@ * [OGC API for Features version 1.0](http://docs.opengeospatial.org/is/17-069r3/17-069r3.html) * [OGC API - Features - Part 3: Filtering and the Common Query Language (CQL)](https://portal.ogc.org/files/96288) * [OpenAPI Specifcation version 3.0.2](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md) +* [OGC API - Features - Part 4: Create, Replace, Update and Delete](https://docs.ogc.org/DRAFTS/20-002.html) ## Notes @@ -63,10 +64,12 @@ JSON document containing feature collection metadata. * items - `/collections/{cid}/items.html` - Features as HTML ## Features +Access to features in a collection. +### GET Produces a dataset of items from the collection (as GeoJSON) -### Request +#### Request Path: `/collections/{cid}/items` ### Parameters @@ -88,7 +91,7 @@ Usually used with an aggregate `transform` function. * `limit=N` - limits the number of features in the response. * `offset=N` - starts the response at an offset. -### Response +#### Response GeoJSON document containing the features resulting from the request query. @@ -99,22 +102,54 @@ GeoJSON document containing the features resulting from the request query. * next - TBD * prev - TBD +### POST +Create a feature in collection. + +#### Request +Path: `/collections/{cid}/items` +Content: JSON document representing a geojson feature. + +#### Response +Empty response with 201 HTTP Status Code. + ## Feature +Provides access to one collection feature. -### Request +### GET +Get one collection feature. + +#### Request Path: `/collections/{cid}/items/{fid}` -#### Parameters +##### Parameters * `properties=PROP-LIST`- return only the given properties (comma-separated) * `transform` - transform the feature geometry by the given geometry function pipeline -### Response +#### Response -#### Links +##### Links * self - `/collections/{cid}/items/{fid}.json` - This document as JSON * alternate - `/collections/{cid}/items/{fid}.html` - This document as HTML * collection - `/collections/{cid}` - The collection document +### PUT +Replace one collection feature. +#### Request +Path: `/collections/{cid}/items/{fid}` +Content: JSON document representing a geojson feature. + +#### Response +Empty response with 200 HTTP Status Code. + +### DELETE +Delete one collection feature. + +#### Request +Path: `/collections/{cid}/items/{fid}` + +#### Response +Empty response with 200 HTTP Status Code. + ## Functions Lists the functions provided by the service. diff --git a/FEATURES.md b/FEATURES.md index 2d9a72b6..cbe58d26 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -87,13 +87,20 @@ It includes [*OGC API - Features*](http://docs.opengeospatial.org/is/17-069r3/17 ### Output formats - [x] GeoJSON +- [ ] GML - [x] JSON for metadata - [x] JSON for non-geometry functions - [ ] `next` link - [ ] `prev` link +### Input formats +- [x] GeoJSON +- [ ] GML + ### Transactions -- [ ] Support POST, PUT, PATCH, DELETE... TBD +- [X] Support POST, PUT, DELETE on tables with primary key +- [ ] Support PATCH... TBD +- [ ] Support Optimistic locking ## User Interface (HTML) - [x] `/home.html` landing page diff --git a/README.md b/README.md index 148e82e0..a78e7178 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ See also our companion project [`pg_tileserv`](https://github.com/CrunchyData/pg * Standard query parameters: `limit`, `bbox`, `bbox-crs`, property filtering, `sortby`, `crs` * Query parameters `filter` and `filter-crs` allow [CQL filtering](https://portal.ogc.org/files/96288), with spatial support * Extended query parameters: `offset`, `properties`, `transform`, `precision`, `groupby` + * Transactions (Create, Replace, Delete) * Data responses are formatted in JSON and [GeoJSON](https://www.rfc-editor.org/rfc/rfc7946.txt) +* Request content for transactions supports [GeoJSON](https://www.rfc-editor.org/rfc/rfc7946.txt) * Provides a simple HTML user interface, with web maps to view spatial data * Uses the power of PostgreSQL to reduce the amount of code and to make data definition easy and familiar. @@ -46,6 +48,8 @@ For a full list of software capabilities see [FEATURES](FEATURES.md). * [*OGC API - Features - Part 2: Coordinate Reference Systems by Reference*](https://docs.ogc.org/is/18-058/18-058.html) * [**DRAFT** *OGC API - Features - Part 3: Filtering*](http://docs.ogc.org/DRAFTS/19-079r1.html) * [**DRAFT** *Common Query Language (CQL2)*](https://docs.ogc.org/DRAFTS/21-065.html) +* [**DRAFT** *OGC API - Features - Part 4: Create, Replace, Update and Delete*](https://docs.ogc.org/DRAFTS/20-002.html) + * [*GeoJSON*](https://www.rfc-editor.org/rfc/rfc7946.txt) ## Download diff --git a/hugo/content/usage/_index.md b/hugo/content/usage/_index.md index 181895f7..114d2bef 100644 --- a/hugo/content/usage/_index.md +++ b/hugo/content/usage/_index.md @@ -10,5 +10,6 @@ This section describes how to use `pg_featureserv`. It covers the following topi * How the [Web Service API](./api/) works * How to publish [feature collections](./collections/) backed by PostGIS tables or views * How to [query features](./query_data/) from feature collections +* How to [create replace delete feature](./create_replace_delete_feature/) in feature collections * How to publish database [functions](./functions/) * How to [execute functions](./query_function/) diff --git a/hugo/content/usage/create_replace_delete_feature.md b/hugo/content/usage/create_replace_delete_feature.md new file mode 100644 index 00000000..3a2fce70 --- /dev/null +++ b/hugo/content/usage/create_replace_delete_feature.md @@ -0,0 +1,49 @@ +--- +title: "Create Replace Delete Feature" +date: +draft: false +weight: 150 +--- + +Transaction on Feature collections is supported. + +## Create feature + +POST query to the path `/collections/{collid}/items` allows to create +a new feature in a feature collection. + +The geojson feature must be part of the request body. +If the geometry geometry crs is different from the storage crs, the geometry will be transformed. +Missing properties will be ignored and the table default value for the column will be applied. +The id specified in the body is ignored and the database default value is used to create the feature. + +#### Example +``` +curl -i --request "POST" 'http://localhost:9000/collections/public.tramway_stations/items' -d '{"type":"Feature","id":"129","geometry":{"type":"Point","coordinates":[-71.222868058,46.836016945,0]},"properties":{"description":null,"diffusion":"Publique","niveau_rstc":"Tramway","nom":"Hôpital Enfant-Jésus","objectid":129,"type_station":"Reguliere"}}' +``` + +## Replace feature + +PUT query to the path `/collections/{collid}/items/{fid}` allows to replace +a feature in a feature collection. + +The geojson feature must be part of the request body. +If the geometry geometry crs is different from the storage crs, the geometry will be transformed. +Missing properties will be replaced with null (unless a database trigger is applied) +The id specified in the body is ignored. + +#### Example +``` +curl -i --request "PUT" 'http://localhost:9000/collections/public.tramway_stations/items/129.json' -d '{"type":"Feature","id":"129","geometry":{"type":"Point","coordinates":[-71.222868058,46.836016945,0]},"properties":{"description":null,"diffusion":"Publique","niveau_rstc":"Tramway","nom":"Hôpital Enfant-Jésus","objectid":129,"type_station":"Reguliere"}}' +``` + +## Delete feature + +DELETE query to the path `/collections/{collid}/items/{fid}` allows to delete +a feature in a feature collection. + +#### Example +``` +curl -i --request "Delete" 'http://localhost:9000/collections/public.tramway_stations/items/129.json' +``` + diff --git a/internal/api/api.go b/internal/api/api.go index 92c83a02..799b9d8a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -482,6 +482,8 @@ var conformance = Conformance{ "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30", "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete", + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/features", }, } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 263f70da..661fd6e3 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -355,10 +355,36 @@ func GetOpenAPIContent(urlBase string) *openapi3.Swagger { }, }, }, + Post: &openapi3.Operation{ + OperationID: "createCollectionFeature", + Parameters: openapi3.Parameters{ + ¶mCollectionID, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Description: "Feature", + Required: true, + /* + // TODO: create schema for input? + Content: openapi3.NewContentWithJSONSchemaRef( + &openapi3.SchemaRef{ + Ref: "http://geojson.org/schema/Feature.json", + }, + ), + */ + }, + }, + Responses: openapi3.Responses{ + "201": &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: "Created"}, + }, + }, + }, }, apiBase + "collections/{collectionId}/items/{featureId}": &openapi3.PathItem{ - Summary: "Single feature data from collection", - Description: "Provides access to a single feature identitfied by {featureId} from the specified collection", + Summary: "Feature in collection", + Description: "Gets, Replaces or Deletes Single Feature in collection.", Get: &openapi3.Operation{ OperationID: "getCollectionFeature", Parameters: openapi3.Parameters{ @@ -393,6 +419,64 @@ func GetOpenAPIContent(urlBase string) *openapi3.Swagger { }, }, }, + Put: &openapi3.Operation{ + OperationID: "replaceCollectionFeature", + Parameters: openapi3.Parameters{ + ¶mCollectionID, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "featureId", + Description: "ID of feature in collection to replace.", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + AllowEmptyValue: false, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Description: "Feature", + Required: true, + /* + // TODO: create schema for input? + Content: openapi3.NewContentWithJSONSchemaRef( + &openapi3.SchemaRef{ + Ref: "http://geojson.org/schema/Feature.json", + }, + ), + */ + }, + }, + Responses: openapi3.Responses{ + "204": &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: "No Content"}, + }, + }, + }, + Delete: &openapi3.Operation{ + OperationID: "deleteCollectionFeature", + Parameters: openapi3.Parameters{ + ¶mCollectionID, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "featureId", + Description: "ID of feature in collection to delete.", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + AllowEmptyValue: false, + }, + }, + }, + Responses: openapi3.Responses{ + "204": &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: "No Content"}, + }, + }, + }, }, apiBase + "functions": &openapi3.PathItem{ Summary: "Functions metadata", diff --git a/internal/data/catalog.go b/internal/data/catalog.go index b56590f6..74f7a4e5 100644 --- a/internal/data/catalog.go +++ b/internal/data/catalog.go @@ -47,6 +47,15 @@ type Catalog interface { // It returns an empty string if the table or feature does not exist TableFeature(ctx context.Context, name string, id string, param *QueryParam) (string, error) + // ReplaceTableFeature replaces a feature + ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error + + // CreateTableFeature creates a feature + CreateTableFeature(ctx context.Context, name string, feature Feature) error + + // DeleteTableFeature deletes a feature + DeleteTableFeature(ctx context.Context, name string, id string) error + Functions() ([]*Function, error) // FunctionByName returns the function with given name. @@ -163,3 +172,17 @@ func FunctionQualifiedId(name string) string { } return SchemaPostGISFTW + "." + name } + +type Geometry struct { + Type string `json:"type"` + Coordinates interface{} `json:"coordinates"` + CRS map[string]interface{} `json:"crs,omitempty"` +} + +// A Feature corresponds to GeoJSON feature object +type Feature struct { + ID interface{} `json:"id,omitempty"` + Type string `json:"type"` + Geometry *Geometry `json:"geometry"` + Properties map[string]interface{} `json:"properties"` +} diff --git a/internal/data/catalog_db.go b/internal/data/catalog_db.go index acbc1107..e3381cf7 100644 --- a/internal/data/catalog_db.go +++ b/internal/data/catalog_db.go @@ -234,6 +234,60 @@ func (cat *catalogDB) TableFeature(ctx context.Context, name string, id string, return features[0], nil } +func (cat *catalogDB) CreateTableFeature(ctx context.Context, name string, feature Feature) error { + tbl, err := cat.TableByName(name) + if err != nil { + return err + } + sql, argValues, err := sqlCreateFeature(tbl, feature) + log.Debug("Create feature query: " + sql) + result, err := cat.dbconn.Exec(ctx, sql, argValues...) + if err != nil { + return err + } + rows := result.RowsAffected() + if rows != 1 { + return fmt.Errorf("expected to affect 1 row, affected %d", rows) + } + return nil +} + +func (cat *catalogDB) ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error { + tbl, err := cat.TableByName(name) + if err != nil { + return err + } + sql, argValues, err := sqlReplaceFeature(tbl, id, feature) + log.Debug("Replace feature query: " + sql) + result, err := cat.dbconn.Exec(ctx, sql, argValues...) + if err != nil { + return err + } + rows := result.RowsAffected() + if rows != 1 { + return fmt.Errorf("expected to affect 1 row, affected %d", rows) + } + return nil +} + +func (cat *catalogDB) DeleteTableFeature(ctx context.Context, name string, id string) error { + tbl, err := cat.TableByName(name) + if err != nil { + return err + } + sql, argValues := sqlDeleteFeature(tbl, id) + log.Debug("Delete feature query: " + sql) + result, err := cat.dbconn.Exec(ctx, sql, argValues...) + if err != nil { + return err + } + rows := result.RowsAffected() + if rows != 1 { + return fmt.Errorf("expected to affect 1 row, affected %d", rows) + } + return nil +} + func (cat *catalogDB) refreshTables(force bool) { // TODO: refresh on timed basis? if force || isStartup { diff --git a/internal/data/catalog_mock.go b/internal/data/catalog_mock.go index f5d339d1..076c7c83 100644 --- a/internal/data/catalog_mock.go +++ b/internal/data/catalog_mock.go @@ -242,6 +242,18 @@ func (cat *CatalogMock) TableFeature(ctx context.Context, name string, id string return features[index].toJSON(propNames), nil } +func (cat *CatalogMock) CreateTableFeature(ctx context.Context, name string, feature Feature) error { + return nil +} + +func (cat *CatalogMock) ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error { + return nil +} + +func (cat *CatalogMock) DeleteTableFeature(ctx context.Context, name string, id string) error { + return nil +} + func (cat *CatalogMock) Functions() ([]*Function, error) { return cat.FunctionDefs, nil } diff --git a/internal/data/db_sql.go b/internal/data/db_sql.go index be9a456f..08cdf5bf 100644 --- a/internal/data/db_sql.go +++ b/internal/data/db_sql.go @@ -1,6 +1,7 @@ package data import ( + "encoding/json" "fmt" "strconv" "strings" @@ -22,7 +23,6 @@ import ( */ const forceTextTSVECTOR = "tsvector" - const sqlTables = `SELECT Format('%s.%s', n.nspname, c.relname) AS id, n.nspname AS schema, @@ -57,6 +57,7 @@ AND has_table_privilege(c.oid, 'select') AND postgis_typmod_srid(a.atttypmod) > 0 ORDER BY id ` + const sqlFunctionsTemplate = `WITH proargs AS ( SELECT p.oid, @@ -191,6 +192,89 @@ func sqlFeature(tbl *Table, param *QueryParam) string { return sql } +func getColumnValues(tbl *Table, feature Feature, includeOnlySetProperties bool) ([]string, []string, []interface{}) { + var columnNames, columnIndex []string + var columnValues []interface{} + var i = 2 + + for _, column := range tbl.Columns { + val, ok := feature.Properties[column] + + if !includeOnlySetProperties || (ok && val != nil) { + columnNames = append(columnNames, column) + columnIndex = append(columnIndex, fmt.Sprintf("$%v", i)) + columnValues = append(columnValues, val) + i++ + } + } + + return columnNames, columnIndex, columnValues +} + +func buildGeometrySQL(tbl *Table) string { + if len(tbl.Columns) > 0 { + return fmt.Sprintf("ST_Transform(ST_GeomFromGeoJSON($1),%v)", tbl.Srid) + } + return fmt.Sprintf("ST_Transform(ST_GeomFromGeoJSON($1),%v)", tbl.Srid) +} + +func buildUpdateSetClause(columnNames []string, columnIndex []string) string { + var setClause string + for index := 0; index < len(columnNames); index++ { + setClause += fmt.Sprintf(", %s=%s", columnNames[index], columnIndex[index]) + } + return setClause +} + +func sqlCreateFeature(tbl *Table, feature Feature) (string, []interface{}, error) { + columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, true) + + columnNamesStr := strings.Join(columnNames, ",") + columnIndexStr := strings.Join(columnIndex, ",") + geomSQL := buildGeometrySQL(tbl) + + sql := fmt.Sprintf("INSERT INTO \"%s\".\"%s\" (%s, %s) VALUES (%s, %v);", tbl.Schema, tbl.Table, columnNamesStr, tbl.GeometryColumn, columnIndexStr, geomSQL) + + var err error + argValues := make([]interface{}, len(columnValues)+1) + argValues[0], err = json.Marshal(feature.Geometry) + if err != nil { + return "", nil, err + } + + copy(argValues[1:], columnValues) + + return sql, argValues, nil +} + +func sqlReplaceFeature(tbl *Table, id string, feature Feature) (string, []interface{}, error) { + columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, false) + + geomSQL := buildGeometrySQL(tbl) + setClause := buildUpdateSetClause(columnNames, columnIndex) + + sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s=%v%s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, tbl.GeometryColumn, geomSQL, setClause, tbl.IDColumn, len(tbl.Columns)+2) + + var err error + argValues := make([]interface{}, len(columnValues)+2) + argValues[0], err = json.Marshal(feature.Geometry) + if err != nil { + return "", nil, err + } + + copy(argValues[1:], columnValues) + argValues[len(columnValues)+1] = id + + return sql, argValues, nil +} + +func sqlDeleteFeature(tbl *Table, id string) (string, []interface{}) { + sql := fmt.Sprintf("DELETE FROM \"%s\".\"%s\" WHERE \"%v\" = $1;", tbl.Schema, tbl.Table, tbl.IDColumn) + argValues := make([]interface{}, 1) + argValues[0] = id + return sql, argValues +} + func sqlCqlFilter(sql string) string { //log.Debug("SQL = " + sql) if len(sql) == 0 { diff --git a/internal/service/handler.go b/internal/service/handler.go index 32c82a5d..896adc4f 100644 --- a/internal/service/handler.go +++ b/internal/service/handler.go @@ -16,7 +16,9 @@ package service import ( "bytes" "context" + "encoding/json" "fmt" + "io/ioutil" "net/http" "strings" @@ -262,32 +264,52 @@ func handleCollectionItems(w http.ResponseWriter, r *http.Request) *appError { //--- extract request parameters name := getRequestVar(routeVarID, r) - reqParam, err := parseRequestParams(r) - if err != nil { - return appErrorMsg(err, err.Error(), http.StatusBadRequest) - } + ctx := r.Context() + switch r.Method { + case http.MethodGet: + reqParam, err := parseRequestParams(r) + if err != nil { + return appErrorMsg(err, err.Error(), http.StatusBadRequest) + } - tbl, err1 := catalogInstance.TableByName(name) - if err1 != nil { - return appErrorInternalFmt(err1, api.ErrMsgCollectionAccess, name) - } - if tbl == nil { - return appErrorNotFoundFmt(err1, api.ErrMsgCollectionNotFound, name) - } - param, err := createQueryParams(&reqParam, tbl.Columns, tbl.Srid) - if err != nil { - return appErrorBadRequest(err, err.Error()) - } - param.Filter = parseFilter(reqParam.Values, tbl.DbTypes) + tbl, err1 := catalogInstance.TableByName(name) + if err1 != nil { + return appErrorInternalFmt(err1, api.ErrMsgCollectionAccess, name) + } + if tbl == nil { + return appErrorNotFoundFmt(err1, api.ErrMsgCollectionNotFound, name) + } + param, err := createQueryParams(&reqParam, tbl.Columns, tbl.Srid) + if err != nil { + return appErrorBadRequest(err, err.Error()) + } + param.Filter = parseFilter(reqParam.Values, tbl.DbTypes) - ctx := r.Context() - switch format { - case api.FormatJSON: - return writeItemsJSON(ctx, w, name, param, urlBase) - case api.FormatHTML: - return writeItemsHTML(w, tbl, name, query, urlBase) + switch format { + case api.FormatJSON: + return writeItemsJSON(ctx, w, name, param, urlBase) + case api.FormatHTML: + return writeItemsHTML(w, tbl, name, query, urlBase) + } + return nil + case http.MethodPost: + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + print(body) + var feature data.Feature + err = json.Unmarshal([]byte(body), &feature) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + catalogInstance.CreateTableFeature(ctx, name, feature) + w.WriteHeader(http.StatusCreated) + return nil + + default: + return appErrorInternalFmt(fmt.Errorf("Method not allowed: %s", r.Method), "") } - return nil } func writeItemsHTML(w http.ResponseWriter, tbl *data.Table, name string, query string, urlBase string) *appError { @@ -357,20 +379,55 @@ func handleItem(w http.ResponseWriter, r *http.Request) *appError { if tbl == nil { return appErrorNotFoundFmt(err1, api.ErrMsgCollectionNotFound, name) } + + ctx := r.Context() param, errQuery := createQueryParams(&reqParam, tbl.Columns, tbl.Srid) - if errQuery == nil { - ctx := r.Context() - switch format { - case api.FormatJSON: - return writeItemJSON(ctx, w, name, fid, param, urlBase) - case api.FormatHTML: - return writeItemHTML(w, tbl, name, fid, query, urlBase) - default: - return nil + feature, err := catalogInstance.TableFeature(ctx, name, fid, param) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgDataReadError, name) + } + if len(feature) == 0 { + return appErrorNotFoundFmt(nil, api.ErrMsgFeatureNotFound, fid) + } + + switch r.Method { + case http.MethodGet: + if errQuery == nil { + switch format { + case api.FormatJSON: + return writeItemJSON(ctx, w, feature, urlBase) + case api.FormatHTML: + return writeItemHTML(w, tbl, name, fid, query, urlBase) + default: + return nil + } + } else { + return appErrorInternalFmt(errQuery, api.ErrMsgInvalidQuery) + } + case http.MethodPut: + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + print(body) + var inputFeature data.Feature + err = json.Unmarshal([]byte(body), &inputFeature) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + catalogInstance.ReplaceTableFeature(ctx, name, fid, inputFeature) + w.WriteHeader(http.StatusNoContent) + return nil + case http.MethodDelete: + err := catalogInstance.DeleteTableFeature(ctx, name, fid) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) } - } else { - return appErrorInternalFmt(errQuery, api.ErrMsgInvalidQuery) + w.WriteHeader(http.StatusNoContent) + return nil + default: + return appErrorInternalFmt(fmt.Errorf("Method not allowed: %s", r.Method), "") } } @@ -394,16 +451,7 @@ func writeItemHTML(w http.ResponseWriter, tbl *data.Table, name string, fid stri return writeHTML(w, nil, context, ui.PageItem()) } -func writeItemJSON(ctx context.Context, w http.ResponseWriter, name string, fid string, param *data.QueryParam, urlBase string) *appError { - //--- query data for request - feature, err := catalogInstance.TableFeature(ctx, name, fid, param) - if err != nil { - return appErrorInternalFmt(err, api.ErrMsgDataReadError, name) - } - if len(feature) == 0 { - return appErrorNotFoundFmt(nil, api.ErrMsgFeatureNotFound, fid) - } - +func writeItemJSON(ctx context.Context, w http.ResponseWriter, feature string, urlBase string) *appError { //--- assemble resonse //content := feature // for now can't add links to feature JSON From a83151635cd5348113993bda08000249cbf7009a Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Tue, 28 Nov 2023 06:58:36 -0500 Subject: [PATCH 2/7] better support for null/unset geometry, added Location header on create feature. --- internal/data/catalog.go | 2 +- internal/data/catalog_db.go | 18 ++++----- internal/data/catalog_mock.go | 4 +- internal/data/db_sql.go | 71 +++++++++++++++++------------------ internal/service/handler.go | 16 ++++++-- 5 files changed, 60 insertions(+), 51 deletions(-) diff --git a/internal/data/catalog.go b/internal/data/catalog.go index 74f7a4e5..a6726cec 100644 --- a/internal/data/catalog.go +++ b/internal/data/catalog.go @@ -51,7 +51,7 @@ type Catalog interface { ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error // CreateTableFeature creates a feature - CreateTableFeature(ctx context.Context, name string, feature Feature) error + CreateTableFeature(ctx context.Context, name string, feature Feature) (string, error) // DeleteTableFeature deletes a feature DeleteTableFeature(ctx context.Context, name string, id string) error diff --git a/internal/data/catalog_db.go b/internal/data/catalog_db.go index e3381cf7..267a9201 100644 --- a/internal/data/catalog_db.go +++ b/internal/data/catalog_db.go @@ -234,22 +234,22 @@ func (cat *catalogDB) TableFeature(ctx context.Context, name string, id string, return features[0], nil } -func (cat *catalogDB) CreateTableFeature(ctx context.Context, name string, feature Feature) error { +func (cat *catalogDB) CreateTableFeature(ctx context.Context, name string, feature Feature) (string, error) { tbl, err := cat.TableByName(name) if err != nil { - return err + return "", err } sql, argValues, err := sqlCreateFeature(tbl, feature) log.Debug("Create feature query: " + sql) - result, err := cat.dbconn.Exec(ctx, sql, argValues...) + row := cat.dbconn.QueryRow(ctx, sql, argValues...) + var featureId string + + err = row.Scan(&featureId) + if err != nil { - return err - } - rows := result.RowsAffected() - if rows != 1 { - return fmt.Errorf("expected to affect 1 row, affected %d", rows) + return "", err } - return nil + return featureId, err } func (cat *catalogDB) ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error { diff --git a/internal/data/catalog_mock.go b/internal/data/catalog_mock.go index 076c7c83..435a1a8e 100644 --- a/internal/data/catalog_mock.go +++ b/internal/data/catalog_mock.go @@ -242,8 +242,8 @@ func (cat *CatalogMock) TableFeature(ctx context.Context, name string, id string return features[index].toJSON(propNames), nil } -func (cat *CatalogMock) CreateTableFeature(ctx context.Context, name string, feature Feature) error { - return nil +func (cat *CatalogMock) CreateTableFeature(ctx context.Context, name string, feature Feature) (string, error) { + return "", nil } func (cat *CatalogMock) ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error { diff --git a/internal/data/db_sql.go b/internal/data/db_sql.go index 08cdf5bf..0fd8cebd 100644 --- a/internal/data/db_sql.go +++ b/internal/data/db_sql.go @@ -192,11 +192,31 @@ func sqlFeature(tbl *Table, param *QueryParam) string { return sql } -func getColumnValues(tbl *Table, feature Feature, includeOnlySetProperties bool) ([]string, []string, []interface{}) { +func getColumnValues(tbl *Table, feature Feature, includeOnlySetProperties bool) ([]string, []string, []interface{}, error) { var columnNames, columnIndex []string var columnValues []interface{} - var i = 2 + var i = 1 + + var geometryStr []byte + if feature.Geometry != nil { + var err error + geometryStr, err = json.Marshal(feature.Geometry) + if err != nil { + return nil, nil, nil, err + } + } + if feature.Geometry != nil { + columnNames = append(columnNames, tbl.GeometryColumn) + columnIndex = append(columnIndex, fmt.Sprintf("ST_Transform(ST_GeomFromGeoJSON($%v),%v)", i, tbl.Srid)) + columnValues = append(columnValues, geometryStr) + i++ + } else if !includeOnlySetProperties { + columnNames = append(columnNames, tbl.GeometryColumn) + columnIndex = append(columnIndex, fmt.Sprintf("$%v", i)) + columnValues = append(columnValues, feature.Geometry) + i++ + } for _, column := range tbl.Columns { val, ok := feature.Properties[column] @@ -208,14 +228,7 @@ func getColumnValues(tbl *Table, feature Feature, includeOnlySetProperties bool) } } - return columnNames, columnIndex, columnValues -} - -func buildGeometrySQL(tbl *Table) string { - if len(tbl.Columns) > 0 { - return fmt.Sprintf("ST_Transform(ST_GeomFromGeoJSON($1),%v)", tbl.Srid) - } - return fmt.Sprintf("ST_Transform(ST_GeomFromGeoJSON($1),%v)", tbl.Srid) + return columnNames, columnIndex, columnValues, nil } func buildUpdateSetClause(columnNames []string, columnIndex []string) string { @@ -227,45 +240,31 @@ func buildUpdateSetClause(columnNames []string, columnIndex []string) string { } func sqlCreateFeature(tbl *Table, feature Feature) (string, []interface{}, error) { - columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, true) - - columnNamesStr := strings.Join(columnNames, ",") - columnIndexStr := strings.Join(columnIndex, ",") - geomSQL := buildGeometrySQL(tbl) - - sql := fmt.Sprintf("INSERT INTO \"%s\".\"%s\" (%s, %s) VALUES (%s, %v);", tbl.Schema, tbl.Table, columnNamesStr, tbl.GeometryColumn, columnIndexStr, geomSQL) - - var err error - argValues := make([]interface{}, len(columnValues)+1) - argValues[0], err = json.Marshal(feature.Geometry) + columnNames, columnIndex, columnValues, err := getColumnValues(tbl, feature, true) if err != nil { return "", nil, err } - copy(argValues[1:], columnValues) + columnNamesStr := strings.Join(columnNames, ",") + columnIndexStr := strings.Join(columnIndex, ",") + + sql := fmt.Sprintf("INSERT INTO \"%s\".\"%s\" (%s) VALUES (%s) RETURNING %s::varchar;", tbl.Schema, tbl.Table, columnNamesStr, columnIndexStr, tbl.IDColumn) - return sql, argValues, nil + return sql, columnValues, nil } func sqlReplaceFeature(tbl *Table, id string, feature Feature) (string, []interface{}, error) { - columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, false) - - geomSQL := buildGeometrySQL(tbl) - setClause := buildUpdateSetClause(columnNames, columnIndex) - - sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s=%v%s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, tbl.GeometryColumn, geomSQL, setClause, tbl.IDColumn, len(tbl.Columns)+2) - - var err error - argValues := make([]interface{}, len(columnValues)+2) - argValues[0], err = json.Marshal(feature.Geometry) + columnNames, columnIndex, columnValues, err := getColumnValues(tbl, feature, true) if err != nil { return "", nil, err } + setClause := buildUpdateSetClause(columnNames, columnIndex) + + sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, setClause, tbl.IDColumn, len(columnNames)) - copy(argValues[1:], columnValues) - argValues[len(columnValues)+1] = id + columnValues = append(columnValues, (id)) - return sql, argValues, nil + return sql, columnValues, nil } func sqlDeleteFeature(tbl *Table, id string) (string, []interface{}) { diff --git a/internal/service/handler.go b/internal/service/handler.go index 896adc4f..cea8260b 100644 --- a/internal/service/handler.go +++ b/internal/service/handler.go @@ -293,17 +293,25 @@ func handleCollectionItems(w http.ResponseWriter, r *http.Request) *appError { } return nil case http.MethodPost: + if format != "json" { + return appErrorInternalFmt(nil, api.ErrMsgInvalidQuery) + } body, err := ioutil.ReadAll(r.Body) if err != nil { return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) } - print(body) var feature data.Feature err = json.Unmarshal([]byte(body), &feature) if err != nil { return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) } - catalogInstance.CreateTableFeature(ctx, name, feature) + var featureId string + featureId, err = catalogInstance.CreateTableFeature(ctx, name, feature) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + urlBase := serveURLBase(r) + w.Header().Set("Location", urlBase+"collections/"+name+"/items/"+featureId+".json") w.WriteHeader(http.StatusCreated) return nil @@ -406,11 +414,13 @@ func handleItem(w http.ResponseWriter, r *http.Request) *appError { return appErrorInternalFmt(errQuery, api.ErrMsgInvalidQuery) } case http.MethodPut: + if format != "json" { + return appErrorInternalFmt(nil, api.ErrMsgInvalidQuery) + } body, err := ioutil.ReadAll(r.Body) if err != nil { return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) } - print(body) var inputFeature data.Feature err = json.Unmarshal([]byte(body), &inputFeature) if err != nil { From bf787559cef0030dd3d0f75c223c5826c90bfb37 Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Sat, 25 Nov 2023 14:34:06 -0500 Subject: [PATCH 3/7] implementation for patch --- API.md | 9 +++++ FEATURES.md | 3 +- README.md | 2 +- hugo/content/usage/_index.md | 2 +- ...> create_update_replace_delete_feature.md} | 14 ++++++++ internal/api/api.go | 1 + internal/api/openapi.go | 36 +++++++++++++++++++ internal/data/catalog.go | 3 ++ internal/data/catalog_db.go | 18 ++++++++++ internal/data/catalog_mock.go | 4 +++ internal/data/db_sql.go | 21 +++++++++++ internal/service/handler.go | 14 ++++++++ 12 files changed, 123 insertions(+), 4 deletions(-) rename hugo/content/usage/{create_replace_delete_feature.md => create_update_replace_delete_feature.md} (73%) diff --git a/API.md b/API.md index c140e589..b2ad5153 100644 --- a/API.md +++ b/API.md @@ -141,6 +141,15 @@ Content: JSON document representing a geojson feature. #### Response Empty response with 200 HTTP Status Code. +### PATCH +Update one collection feature. +#### Request +Path: `/collections/{cid}/items/{fid}` +Content: JSON document representing a geojson feature. + +#### Response +Empty response with 200 HTTP Status Code. + ### DELETE Delete one collection feature. diff --git a/FEATURES.md b/FEATURES.md index cbe58d26..16463bf8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -98,8 +98,7 @@ It includes [*OGC API - Features*](http://docs.opengeospatial.org/is/17-069r3/17 - [ ] GML ### Transactions -- [X] Support POST, PUT, DELETE on tables with primary key -- [ ] Support PATCH... TBD +- [X] Support POST, PUT, PATCH, DELETE on tables with primary key - [ ] Support Optimistic locking ## User Interface (HTML) diff --git a/README.md b/README.md index a78e7178..ac4140a9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ See also our companion project [`pg_tileserv`](https://github.com/CrunchyData/pg * Standard query parameters: `limit`, `bbox`, `bbox-crs`, property filtering, `sortby`, `crs` * Query parameters `filter` and `filter-crs` allow [CQL filtering](https://portal.ogc.org/files/96288), with spatial support * Extended query parameters: `offset`, `properties`, `transform`, `precision`, `groupby` - * Transactions (Create, Replace, Delete) + * Transactions (Create, Update, Replace, Delete) * Data responses are formatted in JSON and [GeoJSON](https://www.rfc-editor.org/rfc/rfc7946.txt) * Request content for transactions supports [GeoJSON](https://www.rfc-editor.org/rfc/rfc7946.txt) * Provides a simple HTML user interface, with web maps to view spatial data diff --git a/hugo/content/usage/_index.md b/hugo/content/usage/_index.md index 114d2bef..b02f8a9f 100644 --- a/hugo/content/usage/_index.md +++ b/hugo/content/usage/_index.md @@ -10,6 +10,6 @@ This section describes how to use `pg_featureserv`. It covers the following topi * How the [Web Service API](./api/) works * How to publish [feature collections](./collections/) backed by PostGIS tables or views * How to [query features](./query_data/) from feature collections -* How to [create replace delete feature](./create_replace_delete_feature/) in feature collections +* How to [create update replace delete feature](./create_update_replace_delete_feature/) in feature collections * How to publish database [functions](./functions/) * How to [execute functions](./query_function/) diff --git a/hugo/content/usage/create_replace_delete_feature.md b/hugo/content/usage/create_update_replace_delete_feature.md similarity index 73% rename from hugo/content/usage/create_replace_delete_feature.md rename to hugo/content/usage/create_update_replace_delete_feature.md index 3a2fce70..13a6b0fc 100644 --- a/hugo/content/usage/create_replace_delete_feature.md +++ b/hugo/content/usage/create_update_replace_delete_feature.md @@ -21,6 +21,20 @@ The id specified in the body is ignored and the database default value is used t ``` curl -i --request "POST" 'http://localhost:9000/collections/public.tramway_stations/items' -d '{"type":"Feature","id":"129","geometry":{"type":"Point","coordinates":[-71.222868058,46.836016945,0]},"properties":{"description":null,"diffusion":"Publique","niveau_rstc":"Tramway","nom":"Hôpital Enfant-Jésus","objectid":129,"type_station":"Reguliere"}}' ``` +## Update feature + +PATCH query to the path `/collections/{collid}/items/{fid}` allows to update +a feature in a feature collection. + +The geojson feature must be part of the request body. +If the geometry geometry crs is different from the storage crs, the geometry will be transformed. +Missing properties will not be updated. +The id specified in the body is ignored. + +#### Example +``` +curl -i --request "PUT" 'http://localhost:9000/collections/public.tramway_stations/items/129.json' -d '{"type":"Feature","id":"129","geometry":{"type":"Point","coordinates":[-71.222868058,46.836016945,0]},"properties":{"description":null,"diffusion":"Publique","niveau_rstc":"Tramway","nom":"Hôpital Enfant-Jésus","objectid":129,"type_station":"Reguliere"}}' +``` ## Replace feature diff --git a/internal/api/api.go b/internal/api/api.go index 799b9d8a..45b5a8bf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -483,6 +483,7 @@ var conformance = Conformance{ "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete", + "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/update", "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/features", }, } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 661fd6e3..03ffa22f 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -419,6 +419,42 @@ func GetOpenAPIContent(urlBase string) *openapi3.Swagger { }, }, }, + Patch: &openapi3.Operation{ + OperationID: "updateCollectionFeature", + Parameters: openapi3.Parameters{ + ¶mCollectionID, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "featureId", + Description: "ID of feature in collection to update.", + In: "path", + Required: true, + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + AllowEmptyValue: false, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Description: "Feature", + Required: true, + /* + // TODO: create schema for input? + Content: openapi3.NewContentWithJSONSchemaRef( + &openapi3.SchemaRef{ + Ref: "http://geojson.org/schema/Feature.json", + }, + ), + */ + }, + }, + Responses: openapi3.Responses{ + "204": &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: "No Content"}, + }, + }, + }, Put: &openapi3.Operation{ OperationID: "replaceCollectionFeature", Parameters: openapi3.Parameters{ diff --git a/internal/data/catalog.go b/internal/data/catalog.go index a6726cec..24a634ee 100644 --- a/internal/data/catalog.go +++ b/internal/data/catalog.go @@ -50,6 +50,9 @@ type Catalog interface { // ReplaceTableFeature replaces a feature ReplaceTableFeature(ctx context.Context, name string, id string, feature Feature) error + // UpdateTableFeature updates a feature + UpdateTableFeature(ctx context.Context, name string, id string, feature Feature) error + // CreateTableFeature creates a feature CreateTableFeature(ctx context.Context, name string, feature Feature) (string, error) diff --git a/internal/data/catalog_db.go b/internal/data/catalog_db.go index 267a9201..7aee2cd4 100644 --- a/internal/data/catalog_db.go +++ b/internal/data/catalog_db.go @@ -270,6 +270,24 @@ func (cat *catalogDB) ReplaceTableFeature(ctx context.Context, name string, id s return nil } +func (cat *catalogDB) UpdateTableFeature(ctx context.Context, name string, id string, feature Feature) error { + tbl, err := cat.TableByName(name) + if err != nil { + return err + } + sql, argValues, err := sqlUpdateFeature(tbl, id, feature) + log.Debug("Update feature query: " + sql) + result, err := cat.dbconn.Exec(ctx, sql, argValues...) + if err != nil { + return err + } + rows := result.RowsAffected() + if rows != 1 { + return fmt.Errorf("expected to affect 1 row, affected %d", rows) + } + return nil +} + func (cat *catalogDB) DeleteTableFeature(ctx context.Context, name string, id string) error { tbl, err := cat.TableByName(name) if err != nil { diff --git a/internal/data/catalog_mock.go b/internal/data/catalog_mock.go index 435a1a8e..8876e35c 100644 --- a/internal/data/catalog_mock.go +++ b/internal/data/catalog_mock.go @@ -250,6 +250,10 @@ func (cat *CatalogMock) ReplaceTableFeature(ctx context.Context, name string, id return nil } +func (cat *CatalogMock) UpdateTableFeature(ctx context.Context, name string, id string, feature Feature) error { + return nil +} + func (cat *CatalogMock) DeleteTableFeature(ctx context.Context, name string, id string) error { return nil } diff --git a/internal/data/db_sql.go b/internal/data/db_sql.go index 0fd8cebd..d3476e7f 100644 --- a/internal/data/db_sql.go +++ b/internal/data/db_sql.go @@ -267,6 +267,27 @@ func sqlReplaceFeature(tbl *Table, id string, feature Feature) (string, []interf return sql, columnValues, nil } +func sqlUpdateFeature(tbl *Table, id string, feature Feature) (string, []interface{}, error) { + columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, true) + + geomSQL := buildGeometrySQL(tbl) + setClause := buildUpdateSetClause(columnNames, columnIndex) + + sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s=%v%s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, tbl.GeometryColumn, geomSQL, setClause, tbl.IDColumn, len(tbl.Columns)+2) + + var err error + argValues := make([]interface{}, len(columnValues)+2) + argValues[0], err = json.Marshal(feature.Geometry) + if err != nil { + return "", nil, err + } + + copy(argValues[1:], columnValues) + argValues[len(columnValues)+1] = id + + return sql, argValues, nil +} + func sqlDeleteFeature(tbl *Table, id string) (string, []interface{}) { sql := fmt.Sprintf("DELETE FROM \"%s\".\"%s\" WHERE \"%v\" = $1;", tbl.Schema, tbl.Table, tbl.IDColumn) argValues := make([]interface{}, 1) diff --git a/internal/service/handler.go b/internal/service/handler.go index cea8260b..3c125233 100644 --- a/internal/service/handler.go +++ b/internal/service/handler.go @@ -429,6 +429,20 @@ func handleItem(w http.ResponseWriter, r *http.Request) *appError { catalogInstance.ReplaceTableFeature(ctx, name, fid, inputFeature) w.WriteHeader(http.StatusNoContent) return nil + case http.MethodPatch: + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + print(body) + var inputFeature data.Feature + err = json.Unmarshal([]byte(body), &inputFeature) + if err != nil { + return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) + } + catalogInstance.UpdateTableFeature(ctx, name, fid, inputFeature) + w.WriteHeader(http.StatusNoContent) + return nil case http.MethodDelete: err := catalogInstance.DeleteTableFeature(ctx, name, fid) if err != nil { From cd02ebd782f6805705430300a9efff3fa4e89666 Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Tue, 28 Nov 2023 07:04:22 -0500 Subject: [PATCH 4/7] support for null/unset geometry --- internal/data/db_sql.go | 10 +++++----- internal/service/handler.go | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/data/db_sql.go b/internal/data/db_sql.go index d3476e7f..5ccc4138 100644 --- a/internal/data/db_sql.go +++ b/internal/data/db_sql.go @@ -268,14 +268,14 @@ func sqlReplaceFeature(tbl *Table, id string, feature Feature) (string, []interf } func sqlUpdateFeature(tbl *Table, id string, feature Feature) (string, []interface{}, error) { - columnNames, columnIndex, columnValues := getColumnValues(tbl, feature, true) - - geomSQL := buildGeometrySQL(tbl) + columnNames, columnIndex, columnValues, err := getColumnValues(tbl, feature, true) + if err != nil { + return "", nil, err + } setClause := buildUpdateSetClause(columnNames, columnIndex) - sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s=%v%s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, tbl.GeometryColumn, geomSQL, setClause, tbl.IDColumn, len(tbl.Columns)+2) + sql := fmt.Sprintf("UPDATE \"%s\".\"%s\" SET %s WHERE \"%v\" = $%v;", tbl.Schema, tbl.Table, setClause, tbl.IDColumn, len(columnNames)) - var err error argValues := make([]interface{}, len(columnValues)+2) argValues[0], err = json.Marshal(feature.Geometry) if err != nil { diff --git a/internal/service/handler.go b/internal/service/handler.go index 3c125233..9bf393d9 100644 --- a/internal/service/handler.go +++ b/internal/service/handler.go @@ -430,6 +430,9 @@ func handleItem(w http.ResponseWriter, r *http.Request) *appError { w.WriteHeader(http.StatusNoContent) return nil case http.MethodPatch: + if format != "json" { + return appErrorInternalFmt(nil, api.ErrMsgInvalidQuery) + } body, err := ioutil.ReadAll(r.Body) if err != nil { return appErrorInternalFmt(err, api.ErrMsgInvalidQuery) From 99535db6407af5cfffd18f2d4b42ae00e9693db5 Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Wed, 29 Nov 2023 19:55:14 -0500 Subject: [PATCH 5/7] Fix bug with int4 and primary key --- internal/data/db_sql.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/data/db_sql.go b/internal/data/db_sql.go index 5ccc4138..2b6f3f98 100644 --- a/internal/data/db_sql.go +++ b/internal/data/db_sql.go @@ -219,13 +219,20 @@ func getColumnValues(tbl *Table, feature Feature, includeOnlySetProperties bool) } for _, column := range tbl.Columns { val, ok := feature.Properties[column] - + if column == tbl.IDColumn { + continue + } if !includeOnlySetProperties || (ok && val != nil) { columnNames = append(columnNames, column) columnIndex = append(columnIndex, fmt.Sprintf("$%v", i)) - columnValues = append(columnValues, val) + if tbl.DbTypes[column] == "int4" { + columnValues = append(columnValues, int(val.(float64))) + } else { + columnValues = append(columnValues, val) + } i++ } + } return columnNames, columnIndex, columnValues, nil From c8ce9d5d777930ef95f37402f89fc417ec5132ec Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Wed, 29 Nov 2023 19:55:50 -0500 Subject: [PATCH 6/7] fixed issue with QGIS, pg_featureserv not interpreting bbox-crs correctly --- internal/service/param.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/service/param.go b/internal/service/param.go index 4d1eb302..e8c86c55 100644 --- a/internal/service/param.go +++ b/internal/service/param.go @@ -26,6 +26,15 @@ import ( "github.com/CrunchyData/pg_featureserv/internal/data" ) +func getCharsAfterLastSlash(input string) (result string) { + if lastSlashIndex := strings.LastIndex(input, "/"); lastSlashIndex != -1 && lastSlashIndex < len(input)-1 { + result = input[lastSlashIndex+1:] + } else { + result = input + } + return result +} + func parseRequestParams(r *http.Request) (api.RequestParam, error) { queryValues := r.URL.Query() paramValues := extractSingleArgs(queryValues) @@ -145,6 +154,7 @@ func parseString(values api.NameValMap, key string) string { func parseInt(values api.NameValMap, key string, minVal int, maxVal int, defaultVal int) (int, error) { valStr := values[key] + valStr = getCharsAfterLastSlash(valStr) // key not present or missing value if len(valStr) < 1 { return defaultVal, nil From 7f685671e35d077adf1d1528f7b8413125a7fde7 Mon Sep 17 00:00:00 2001 From: Frederic Morin Date: Wed, 29 Nov 2023 19:56:29 -0500 Subject: [PATCH 7/7] Implement options --- internal/service/handler.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/service/handler.go b/internal/service/handler.go index 9bf393d9..4fee9bc9 100644 --- a/internal/service/handler.go +++ b/internal/service/handler.go @@ -266,6 +266,9 @@ func handleCollectionItems(w http.ResponseWriter, r *http.Request) *appError { name := getRequestVar(routeVarID, r) ctx := r.Context() switch r.Method { + case http.MethodOptions: + w.Header().Set("Allow", "GET, POST") + return nil case http.MethodGet: reqParam, err := parseRequestParams(r) if err != nil { @@ -400,6 +403,9 @@ func handleItem(w http.ResponseWriter, r *http.Request) *appError { } switch r.Method { + case http.MethodOptions: + w.Header().Set("Allow", "GET, PUT, PATCH, DELETE") + return nil case http.MethodGet: if errQuery == nil { switch format {