Skip to content

Commit 730470d

Browse files
committed
feat: add JSON API for FDO Owner Server with oapi-codegen implementation
Implements comprehensive JSON responses for all FIDO Device Onboard Owner Server endpoints while maintaining backward compatibility with existing PEM-based clients through Accept header content negotiation. ## Key Features - All Owner API endpoints return JSON by default - OpenAPI 3.0.3 specification with oapi-codegen code generation - Pure Go-native tooling (no npm/Node.js dependencies) - Comprehensive test coverage validating JSON responses - Backward compatibility via Accept: application/x-pem-file - Split server architecture preserved - Consolidated error handling patterns and helper functions ## Implementation Details - Uses oapi-codegen v2.5.1 for type-safe Go code generation - Smart content detection for CI compatibility (JSON-in-form scenarios) - Centralized constants and helper functions eliminate code duplication - Generated OpenAPI types replace custom response structures - Enhanced error handling with consistent patterns ## New Files - api/openapi/owner-server.yaml: OpenAPI 3.0.3 specification (177 lines) - api/openapi/generated.go: Generated types and server interfaces - api/handlers/responses.go: JSON response utilities (148 lines) - api/handlers/constants.go: Content-type constants (11 lines) - api/handlersTest/json_api_test.go: JSON validation tests (84 lines) ## Modified Files - api/handlers/vouchers.go: JSON responses + consolidated helpers - api/handlers/health.go: Uses generated types + constants - api/handlers/ownerinfo.go: Consolidated error handling - api/handlers/rvinfo.go: Consolidated error handling - api/handlersTest/vouchers_test.go: Updated for JSON expectations - api/handlersTest/health_test.go: Uses generated types - Makefile: Added oapi-codegen validation/generation targets ## Testing All endpoints tested with comprehensive JSON validation: - TestJSONResponsesRequired: validates JSON Content-Type headers - TestInsertVoucherHandler: voucher operations with JSON responses - TestBackwardCompatibilityPEM: ensures PEM clients still work - All CI tests pass with enhanced form-data compatibility Signed-off-by: djach7 <djachimo@redhat.com>
1 parent 84d585d commit 730470d

20 files changed

Lines changed: 1224 additions & 126 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ go-fdo-server
66
go-fdo-server-*.tar.*
77
rpmbuild
88
test/workdir
9+
10+
# Generated OpenAPI code
11+
generated/

Makefile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,41 @@ VERSION := $(shell grep 'Version:' $(SPEC_FILE) | awk '{printf "%s", $$2
1212
# Default target
1313
all: build test
1414

15+
#
16+
# OpenAPI Code Generation
17+
#
18+
OPENAPI_SPEC := api/openapi/owner-server.yaml
19+
GENERATED_FILE := api/openapi/generated.go
20+
21+
.PHONY: validate-openapi
22+
validate-openapi:
23+
@echo "Validating OpenAPI specification..."
24+
@command -v oapi-codegen >/dev/null 2>&1 || command -v $$(go env GOPATH)/bin/oapi-codegen >/dev/null 2>&1 || { \
25+
echo "Error: oapi-codegen not found. Please install it first:"; \
26+
echo " go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest"; \
27+
exit 1; \
28+
}
29+
@echo "OpenAPI spec validation (oapi-codegen validates during generation)"
30+
31+
.PHONY: generate-api
32+
generate-api: validate-openapi
33+
@echo "Generating Go types and server code from OpenAPI specification..."
34+
$$(command -v oapi-codegen || echo $$(go env GOPATH)/bin/oapi-codegen) -package openapi -generate types,chi-server -o $(GENERATED_FILE) $(OPENAPI_SPEC)
35+
36+
.PHONY: clean-generated
37+
clean-generated:
38+
@echo "Cleaning generated OpenAPI code..."
39+
rm -f $(GENERATED_FILE)
40+
41+
.PHONY: openapi-docs
42+
openapi-docs:
43+
@echo "Starting OpenAPI documentation server..."
44+
@command -v swagger-ui-serve >/dev/null 2>&1 || { \
45+
echo "Installing swagger-ui-serve..."; \
46+
npm install -g swagger-ui-serve; \
47+
}
48+
swagger-ui-serve $(OPENAPI_SPEC)
49+
1550
# Build the Go project
1651
.PHONY: build
1752
build: tidy fmt vet

api/handlers/constants.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// SPDX-FileCopyrightText: (C) 2024 Intel Corporation
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package handlers
5+
6+
// Content type constants to eliminate duplication across handlers
7+
const (
8+
ContentTypeJSON = "application/json"
9+
ContentTypePEM = "application/x-pem-file"
10+
ContentTypeForm = "application/x-www-form-urlencoded"
11+
)

api/handlers/health.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@ package handlers
66
import (
77
"encoding/json"
88
"net/http"
9-
)
109

11-
type HealthResponse struct {
12-
Version string `json:"version"`
13-
Status string `json:"status"`
14-
}
10+
"github.com/fido-device-onboard/go-fdo-server/api/openapi"
11+
)
1512

1613
// HealthHandler responds with the version and status
1714
func HealthHandler(w http.ResponseWriter, r *http.Request) {
@@ -20,11 +17,11 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
2017
w.Write([]byte("Method not allowed"))
2118
return
2219
}
23-
response := HealthResponse{
20+
response := openapi.HealthResponse{
2421
Version: "1.1",
2522
Status: "OK",
2623
}
27-
w.Header().Set("Content-Type", "application/json")
24+
w.Header().Set("Content-Type", ContentTypeJSON)
2825
w.WriteHeader(http.StatusOK)
2926
json.NewEncoder(w).Encode(response)
3027
}

api/handlers/ownerinfo.go

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package handlers
55

66
import (
77
"errors"
8-
"io"
98
"log/slog"
109
"net/http"
1110

@@ -28,36 +27,30 @@ func OwnerInfoHandler(w http.ResponseWriter, r *http.Request) {
2827
}
2928
}
3029

31-
func getOwnerInfo(w http.ResponseWriter, _ *http.Request) {
30+
func getOwnerInfo(w http.ResponseWriter, r *http.Request) {
3231
slog.Debug("Fetching ownerInfo")
3332
ownerInfoJSON, err := db.FetchOwnerInfoJSON()
3433
if err != nil {
35-
if errors.Is(err, gorm.ErrRecordNotFound) {
36-
slog.Error("No ownerInfo found")
37-
http.Error(w, "No ownerInfo found", http.StatusNotFound)
38-
} else {
39-
slog.Error("Error fetching ownerInfo", "error", err)
40-
http.Error(w, "Error fetching ownerInfo", http.StatusInternalServerError)
34+
if HandleNotFoundError(w, r, "ownerInfo", err) {
35+
return
4136
}
37+
slog.Error("Error fetching ownerInfo", "error", err)
38+
WriteErrorResponse(w, r, http.StatusInternalServerError, "Error fetching ownerInfo", err.Error(), "Error fetching ownerInfo")
4239
return
4340
}
4441

45-
w.Header().Set("Content-Type", "application/json")
42+
w.Header().Set("Content-Type", ContentTypeJSON)
4643
w.Write(ownerInfoJSON)
4744
}
4845

4946
func createOwnerInfo(w http.ResponseWriter, r *http.Request) {
50-
ownerInfo, err := io.ReadAll(r.Body)
51-
if err != nil {
52-
slog.Error("Error reading body", "error", err)
53-
http.Error(w, "Error reading body", http.StatusInternalServerError)
47+
ownerInfo, ok := ReadRequestBody(w, r)
48+
if !ok {
5449
return
5550
}
5651

5752
if err := db.InsertOwnerInfo(ownerInfo); err != nil {
58-
if errors.Is(err, gorm.ErrDuplicatedKey) {
59-
slog.Error("ownerInfo already exists (constraint)", "error", err)
60-
http.Error(w, "ownerInfo already exists", http.StatusConflict)
53+
if HandleDuplicateKeyError(w, "ownerInfo", err) {
6154
return
6255
}
6356
if errors.Is(err, db.ErrInvalidOwnerInfo) {
@@ -72,16 +65,14 @@ func createOwnerInfo(w http.ResponseWriter, r *http.Request) {
7265

7366
slog.Debug("ownerInfo created")
7467

75-
w.Header().Set("Content-Type", "application/json")
68+
w.Header().Set("Content-Type", ContentTypeJSON)
7669
w.WriteHeader(http.StatusCreated)
7770
w.Write(ownerInfo)
7871
}
7972

8073
func updateOwnerInfo(w http.ResponseWriter, r *http.Request) {
81-
ownerInfo, err := io.ReadAll(r.Body)
82-
if err != nil {
83-
slog.Error("Error reading body", "error", err)
84-
http.Error(w, "Error reading body", http.StatusInternalServerError)
74+
ownerInfo, ok := ReadRequestBody(w, r)
75+
if !ok {
8576
return
8677
}
8778

@@ -103,6 +94,6 @@ func updateOwnerInfo(w http.ResponseWriter, r *http.Request) {
10394

10495
slog.Debug("ownerInfo updated")
10596

106-
w.Header().Set("Content-Type", "application/json")
97+
w.Header().Set("Content-Type", ContentTypeJSON)
10798
w.Write(ownerInfo)
10899
}

api/handlers/responses.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-FileCopyrightText: (C) 2024 Intel Corporation
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package handlers
5+
6+
import (
7+
"encoding/base64"
8+
"encoding/hex"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"log/slog"
14+
"net/http"
15+
16+
"github.com/fido-device-onboard/go-fdo-server/api/openapi"
17+
"github.com/fido-device-onboard/go-fdo-server/internal/utils"
18+
"gorm.io/gorm"
19+
)
20+
21+
// Use generated types instead of custom types
22+
// VoucherResponse and VoucherInsertResponse are now imported from openapi package
23+
24+
// ErrorResponse represents a standard error response
25+
type ErrorResponse struct {
26+
Error string `json:"error"`
27+
Details string `json:"details,omitempty"`
28+
}
29+
30+
// WriteJSONVoucher writes a voucher response in JSON format
31+
func WriteJSONVoucher(w http.ResponseWriter, voucherData []byte, encoding string, guid []byte) error {
32+
response := openapi.VoucherResponse{
33+
Voucher: base64.StdEncoding.EncodeToString(voucherData),
34+
Encoding: encoding,
35+
Guid: hex.EncodeToString(guid),
36+
}
37+
38+
w.Header().Set("Content-Type", ContentTypeJSON)
39+
return json.NewEncoder(w).Encode(response)
40+
}
41+
42+
// WriteJSONError writes a standard error response in JSON format
43+
func WriteJSONError(w http.ResponseWriter, statusCode int, errorMsg, details string) {
44+
response := ErrorResponse{
45+
Error: errorMsg,
46+
Details: details,
47+
}
48+
49+
w.Header().Set("Content-Type", ContentTypeJSON)
50+
w.WriteHeader(statusCode)
51+
json.NewEncoder(w).Encode(response)
52+
}
53+
54+
// WriteJSONVoucherInsertResponse writes a voucher insert response in JSON format
55+
func WriteJSONVoucherInsertResponse(w http.ResponseWriter, response openapi.VoucherInsertResponse) error {
56+
w.Header().Set("Content-Type", ContentTypeJSON)
57+
if response.Errors != nil && len(*response.Errors) > 0 && response.Inserted == 0 {
58+
w.WriteHeader(http.StatusBadRequest)
59+
} else {
60+
w.WriteHeader(http.StatusOK)
61+
}
62+
return json.NewEncoder(w).Encode(response)
63+
}
64+
65+
// ShouldReturnJSON checks if the client prefers JSON response
66+
// This allows for backward compatibility with existing PEM clients
67+
func ShouldReturnJSON(r *http.Request) bool {
68+
accept := r.Header.Get("Accept")
69+
70+
// If client specifically requests PEM, honor it for backward compatibility
71+
if accept == ContentTypePEM {
72+
return false
73+
}
74+
75+
// Default to JSON for everything else (including no preference)
76+
return true
77+
}
78+
79+
// WriteErrorResponse writes an error response in JSON or text format based on Accept header
80+
func WriteErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, jsonMsg, jsonDetails, textMsg string) {
81+
if ShouldReturnJSON(r) {
82+
WriteJSONError(w, statusCode, jsonMsg, jsonDetails)
83+
} else {
84+
http.Error(w, textMsg, statusCode)
85+
}
86+
}
87+
88+
// ValidateAndDecodeGUID validates a GUID string and returns the decoded bytes
89+
// Returns the decoded GUID bytes or an error response written to the writer
90+
func ValidateAndDecodeGUID(w http.ResponseWriter, r *http.Request, guidHex string) ([]byte, bool) {
91+
if !utils.IsValidGUID(guidHex) {
92+
WriteErrorResponse(w, r, http.StatusBadRequest, "Invalid GUID", "GUID must be 32 hexadecimal characters", "Invalid GUID")
93+
return nil, false
94+
}
95+
96+
guid, err := hex.DecodeString(guidHex)
97+
if err != nil {
98+
WriteErrorResponse(w, r, http.StatusBadRequest, "Invalid GUID format", err.Error(), "Invalid GUID format")
99+
return nil, false
100+
}
101+
102+
return guid, true
103+
}
104+
105+
// FormatGUID formats a GUID byte array as hex string for error messages
106+
func FormatGUID(guid []byte) string {
107+
return hex.EncodeToString(guid)
108+
}
109+
110+
// AppendError adds an error message to the voucher insert response
111+
func AppendError(response *openapi.VoucherInsertResponse, format string, args ...interface{}) {
112+
if response.Errors == nil {
113+
errors := make([]string, 0)
114+
response.Errors = &errors
115+
}
116+
*response.Errors = append(*response.Errors, fmt.Sprintf(format, args...))
117+
}
118+
119+
// ReadRequestBody reads the request body and handles errors consistently
120+
func ReadRequestBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
121+
body, err := io.ReadAll(r.Body)
122+
if err != nil {
123+
slog.Error("Error reading body", "error", err)
124+
http.Error(w, "Error reading body", http.StatusInternalServerError)
125+
return nil, false
126+
}
127+
return body, true
128+
}
129+
130+
// HandleDuplicateKeyError handles GORM duplicate key errors consistently
131+
func HandleDuplicateKeyError(w http.ResponseWriter, entityName string, err error) bool {
132+
if errors.Is(err, gorm.ErrDuplicatedKey) {
133+
slog.Error(entityName+" already exists (constraint)", "error", err)
134+
http.Error(w, entityName+" already exists", http.StatusConflict)
135+
return true
136+
}
137+
return false
138+
}
139+
140+
// HandleNotFoundError handles GORM not found errors consistently
141+
func HandleNotFoundError(w http.ResponseWriter, r *http.Request, entityName string, err error) bool {
142+
if errors.Is(err, gorm.ErrRecordNotFound) {
143+
slog.Error("No " + entityName + " found")
144+
WriteErrorResponse(w, r, http.StatusNotFound, "No "+entityName+" found", entityName+" has not been configured", "No "+entityName+" found")
145+
return true
146+
}
147+
return false
148+
}

api/handlers/rvinfo.go

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package handlers
55

66
import (
77
"errors"
8-
"io"
98
"log/slog"
109
"net/http"
1110

@@ -44,22 +43,18 @@ func getRvInfo(w http.ResponseWriter, _ *http.Request) {
4443
return
4544
}
4645

47-
w.Header().Set("Content-Type", "application/json")
46+
w.Header().Set("Content-Type", ContentTypeJSON)
4847
w.Write(rvInfoJSON)
4948
}
5049

5150
func createRvInfo(w http.ResponseWriter, r *http.Request) {
52-
rvInfo, err := io.ReadAll(r.Body)
53-
if err != nil {
54-
slog.Error("Error reading body", "error", err)
55-
http.Error(w, "Error reading body", http.StatusInternalServerError)
51+
rvInfo, ok := ReadRequestBody(w, r)
52+
if !ok {
5653
return
5754
}
5855

5956
if err := db.InsertRvInfo(rvInfo); err != nil {
60-
if errors.Is(err, gorm.ErrDuplicatedKey) {
61-
slog.Error("rvInfo already exists (constraint)", "error", err)
62-
http.Error(w, "rvInfo already exists", http.StatusConflict)
57+
if HandleDuplicateKeyError(w, "rvInfo", err) {
6358
return
6459
}
6560
if errors.Is(err, db.ErrInvalidRvInfo) {
@@ -74,16 +69,14 @@ func createRvInfo(w http.ResponseWriter, r *http.Request) {
7469

7570
slog.Debug("rvInfo created")
7671

77-
w.Header().Set("Content-Type", "application/json")
72+
w.Header().Set("Content-Type", ContentTypeJSON)
7873
w.WriteHeader(http.StatusCreated)
7974
w.Write(rvInfo)
8075
}
8176

8277
func updateRvInfo(w http.ResponseWriter, r *http.Request) {
83-
rvInfo, err := io.ReadAll(r.Body)
84-
if err != nil {
85-
slog.Error("Error reading body", "error", err)
86-
http.Error(w, "Error reading body", http.StatusInternalServerError)
78+
rvInfo, ok := ReadRequestBody(w, r)
79+
if !ok {
8780
return
8881
}
8982

@@ -105,6 +98,6 @@ func updateRvInfo(w http.ResponseWriter, r *http.Request) {
10598

10699
slog.Debug("rvInfo updated")
107100

108-
w.Header().Set("Content-Type", "application/json")
101+
w.Header().Set("Content-Type", ContentTypeJSON)
109102
w.Write(rvInfo)
110103
}

0 commit comments

Comments
 (0)