Skip to content

Commit 1d64a01

Browse files
authored
fix: correct api URL handling in CLI (#570)
1 parent c92b91b commit 1d64a01

File tree

6 files changed

+94
-28
lines changed

6 files changed

+94
-28
lines changed

synkronus-cli/internal/auth/auth.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,13 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9-
"strings"
109
"time"
1110

1211
"github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils"
1312
"github.com/golang-jwt/jwt/v5"
1413
"github.com/spf13/viper"
1514
)
1615

17-
// normalizeBaseURL trims trailing slashes to avoid double slashes when joining paths.
18-
func normalizeBaseURL(base string) string {
19-
return strings.TrimRight(base, "/")
20-
}
21-
2216
// TokenResponse represents the response from the authentication endpoint
2317
type TokenResponse struct {
2418
Token string `json:"token"`
@@ -35,8 +29,8 @@ type Claims struct {
3529

3630
// Login authenticates with the Synkronus API and returns a token
3731
func Login(username, password string) (*TokenResponse, error) {
38-
apiURL := normalizeBaseURL(utils.EnsureScheme(viper.GetString("api.url")))
39-
loginURL := fmt.Sprintf("%s/api/auth/login", apiURL)
32+
apiURL := utils.APIBaseURL(viper.GetString("api.url"))
33+
loginURL := fmt.Sprintf("%s/auth/login", apiURL)
4034

4135
// Prepare login request
4236
loginData := map[string]string{
@@ -97,8 +91,8 @@ func Login(username, password string) (*TokenResponse, error) {
9791

9892
// RefreshToken refreshes the JWT token
9993
func RefreshToken() (*TokenResponse, error) {
100-
apiURL := normalizeBaseURL(utils.EnsureScheme(viper.GetString("api.url")))
101-
refreshURL := fmt.Sprintf("%s/api/auth/refresh", apiURL)
94+
apiURL := utils.APIBaseURL(viper.GetString("api.url"))
95+
refreshURL := fmt.Sprintf("%s/auth/refresh", apiURL)
10296
refreshToken := viper.GetString("auth.refresh_token")
10397

10498
// Prepare refresh request

synkronus-cli/internal/cmd/health.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"fmt"
55
"net/http"
6-
"strings"
76
"time"
87

98
"github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils"
@@ -17,8 +16,8 @@ func init() {
1716
Short: "Check the health of the Synkronus API",
1817
Long: `Verify connectivity to the Synkronus API server.`,
1918
RunE: func(cmd *cobra.Command, args []string) error {
20-
apiURL := strings.TrimRight(utils.EnsureScheme(viper.GetString("api.url")), "/")
21-
healthURL := apiURL + "/health"
19+
origin := utils.OriginURL(viper.GetString("api.url"))
20+
healthURL := origin + "/health"
2221

2322
utils.PrintInfo("Checking API health at %s...", healthURL)
2423

synkronus-cli/internal/utils/url.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,36 @@ func EnsureScheme(raw string) string {
1414
}
1515
return "https://" + raw
1616
}
17+
18+
// NormalizeURL trims trailing slashes from a URL string (after EnsureScheme).
19+
func NormalizeURL(raw string) string {
20+
return strings.TrimRight(EnsureScheme(raw), "/")
21+
}
22+
23+
// APIBaseURL returns the base URL for Synkronus HTTP API routes in openapi/synkronus.yaml,
24+
// which are all under the /api prefix from the deployment origin.
25+
// If the configured URL already ends with /api, it is left unchanged so paths are not doubled.
26+
func APIBaseURL(raw string) string {
27+
base := NormalizeURL(raw)
28+
if base == "" {
29+
return base
30+
}
31+
if strings.HasSuffix(strings.ToLower(base), "/api") {
32+
return base
33+
}
34+
return base + "/api"
35+
}
36+
37+
// OriginURL strips a trailing /api segment (case-insensitive) so callers can reach routes
38+
// served at the site root, e.g. GET /health in the OpenAPI spec.
39+
func OriginURL(raw string) string {
40+
base := NormalizeURL(raw)
41+
if base == "" {
42+
return base
43+
}
44+
lower := strings.ToLower(base)
45+
if strings.HasSuffix(lower, "/api") {
46+
return strings.TrimRight(base[:len(base)-len("/api")], "/")
47+
}
48+
return base
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package utils
2+
3+
import "testing"
4+
5+
func TestAPIBaseURL(t *testing.T) {
6+
tests := []struct {
7+
raw string
8+
want string
9+
}{
10+
{"https://example.com", "https://example.com/api"},
11+
{"https://example.com/", "https://example.com/api"},
12+
{"https://example.com/api", "https://example.com/api"},
13+
{"https://example.com/api/", "https://example.com/api"},
14+
{"http://localhost:8080", "http://localhost:8080/api"},
15+
{"", ""},
16+
}
17+
for _, tt := range tests {
18+
if got := APIBaseURL(tt.raw); got != tt.want {
19+
t.Errorf("APIBaseURL(%q) = %q, want %q", tt.raw, got, tt.want)
20+
}
21+
}
22+
}
23+
24+
func TestOriginURL(t *testing.T) {
25+
tests := []struct {
26+
raw string
27+
want string
28+
}{
29+
{"https://example.com", "https://example.com"},
30+
{"https://example.com/api", "https://example.com"},
31+
{"https://example.com/api/", "https://example.com"},
32+
{"http://localhost:8080/api", "http://localhost:8080"},
33+
{"", ""},
34+
}
35+
for _, tt := range tests {
36+
if got := OriginURL(tt.raw); got != tt.want {
37+
t.Errorf("OriginURL(%q) = %q, want %q", tt.raw, got, tt.want)
38+
}
39+
}
40+
}

synkronus-cli/pkg/client/client.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type Client struct {
5757

5858
// NewClient creates a new Synkronus API client
5959
func NewClient() *Client {
60-
baseURL := strings.TrimRight(utils.EnsureScheme(viper.GetString("api.url")), "/")
60+
baseURL := utils.APIBaseURL(viper.GetString("api.url"))
6161
return &Client{
6262
BaseURL: baseURL,
6363
APIVersion: viper.GetString("api.version"),
@@ -70,7 +70,7 @@ func NewClient() *Client {
7070
// doRequest performs an HTTP request with authentication
7171
// GetVersion retrieves version information from the Synkronus server
7272
func (c *Client) GetVersion() (*SystemVersionInfo, error) {
73-
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/version", c.BaseURL), nil)
73+
req, err := http.NewRequest("GET", fmt.Sprintf("%s/version", c.BaseURL), nil)
7474
if err != nil {
7575
return nil, fmt.Errorf("error creating version request: %w", err)
7676
}
@@ -145,7 +145,7 @@ func (c *Client) GetAppBundleManifest() (map[string]interface{}, error) {
145145

146146
// GetAppBundleVersions retrieves available app bundle versions
147147
func (c *Client) GetAppBundleVersions() (map[string]interface{}, error) {
148-
url := fmt.Sprintf("%s/api/app-bundle/versions", c.BaseURL)
148+
url := fmt.Sprintf("%s/app-bundle/versions", c.BaseURL)
149149
req, err := http.NewRequest("GET", url, nil)
150150
if err != nil {
151151
return nil, err
@@ -216,7 +216,7 @@ func (c *Client) DownloadAppBundleFile(path, destPath string, preview bool) erro
216216
return nil
217217
}
218218

219-
// downloadBinaryToFile performs an authenticated GET on path (must start with /, e.g. /api/dataexport/parquet)
219+
// downloadBinaryToFile performs an authenticated GET on path (must start with /, e.g. /dataexport/parquet)
220220
// and streams the body to destPath. Uses no overall HTTP timeout so large ZIP exports can complete.
221221
func (c *Client) downloadBinaryToFile(path string, destPath string) error {
222222
url := fmt.Sprintf("%s%s", c.BaseURL, path)
@@ -271,17 +271,17 @@ func (c *Client) downloadBinaryToFile(path string, destPath string) error {
271271

272272
// DownloadParquetExport downloads the Parquet export ZIP archive to the specified destination path
273273
func (c *Client) DownloadParquetExport(destPath string) error {
274-
return c.downloadBinaryToFile("/api/dataexport/parquet", destPath)
274+
return c.downloadBinaryToFile("/dataexport/parquet", destPath)
275275
}
276276

277277
// DownloadRawJSONExport downloads the per-observation JSON ZIP export to the specified destination path
278278
func (c *Client) DownloadRawJSONExport(destPath string) error {
279-
return c.downloadBinaryToFile("/api/dataexport/raw-json", destPath)
279+
return c.downloadBinaryToFile("/dataexport/raw-json", destPath)
280280
}
281281

282282
// DownloadAttachmentsExport downloads a ZIP of all current attachments to the specified destination path
283283
func (c *Client) DownloadAttachmentsExport(destPath string) error {
284-
return c.downloadBinaryToFile("/api/attachments/export-zip", destPath)
284+
return c.downloadBinaryToFile("/attachments/export-zip", destPath)
285285
}
286286

287287
// UploadAppBundle uploads a new app bundle
@@ -348,7 +348,7 @@ func (c *Client) UploadAppBundle(bundlePath string) (map[string]interface{}, err
348348

349349
// SwitchAppBundleVersion switches to a specific app bundle version
350350
func (c *Client) SwitchAppBundleVersion(version string) (map[string]interface{}, error) {
351-
url := fmt.Sprintf("%s/api/app-bundle/switch/%s", c.BaseURL, version)
351+
url := fmt.Sprintf("%s/app-bundle/switch/%s", c.BaseURL, version)
352352

353353
req, err := http.NewRequest("POST", url, nil)
354354
if err != nil {
@@ -376,7 +376,7 @@ func (c *Client) SwitchAppBundleVersion(version string) (map[string]interface{},
376376

377377
// SyncPull pulls updated records from the server
378378
func (c *Client) SyncPull(clientID string, currentVersion int64, schemaTypes []string, limit int, pageToken string) (map[string]interface{}, error) {
379-
requestURL := fmt.Sprintf("%s/api/sync/pull", c.BaseURL)
379+
requestURL := fmt.Sprintf("%s/sync/pull", c.BaseURL)
380380

381381
// Build query parameters
382382
var queryParams []string
@@ -455,7 +455,7 @@ func (c *Client) SyncPull(clientID string, currentVersion int64, schemaTypes []s
455455

456456
// SyncPush pushes records to the server
457457
func (c *Client) SyncPush(clientID string, transmissionID string, records []map[string]interface{}) (map[string]interface{}, error) {
458-
url := fmt.Sprintf("%s/api/sync/push", c.BaseURL)
458+
url := fmt.Sprintf("%s/sync/push", c.BaseURL)
459459

460460
// Prepare request body
461461
reqBody := map[string]interface{}{

synkronus-cli/pkg/client/user.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type UserChangePasswordRequest struct {
2828

2929
// CreateUser calls POST /users to create a new user (admin)
3030
func (c *Client) CreateUser(reqBody UserCreateRequest) (map[string]interface{}, error) {
31-
url := fmt.Sprintf("%s/api/users", c.BaseURL)
31+
url := fmt.Sprintf("%s/users", c.BaseURL)
3232
body, err := json.Marshal(reqBody)
3333
if err != nil {
3434
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -60,7 +60,7 @@ func (c *Client) CreateUser(reqBody UserCreateRequest) (map[string]interface{},
6060

6161
// DeleteUser calls DELETE /users/delete/{username} (admin)
6262
func (c *Client) DeleteUser(username string) error {
63-
url := fmt.Sprintf("%s/api/users/delete/%s", c.BaseURL, username)
63+
url := fmt.Sprintf("%s/users/delete/%s", c.BaseURL, username)
6464
request, err := http.NewRequest("DELETE", url, nil)
6565
if err != nil {
6666
return fmt.Errorf("failed to create request: %w", err)
@@ -80,7 +80,7 @@ func (c *Client) DeleteUser(username string) error {
8080

8181
// ResetUserPassword calls POST /users/reset-password (admin)
8282
func (c *Client) ResetUserPassword(reqBody UserResetPasswordRequest) error {
83-
url := fmt.Sprintf("%s/api/users/reset-password", c.BaseURL)
83+
url := fmt.Sprintf("%s/users/reset-password", c.BaseURL)
8484
body, err := json.Marshal(reqBody)
8585
if err != nil {
8686
return fmt.Errorf("failed to marshal request: %w", err)
@@ -105,7 +105,7 @@ func (c *Client) ResetUserPassword(reqBody UserResetPasswordRequest) error {
105105

106106
// ChangeOwnPassword calls POST /users/change-password (self)
107107
func (c *Client) ChangeOwnPassword(reqBody UserChangePasswordRequest) error {
108-
url := fmt.Sprintf("%s/api/users/change-password", c.BaseURL)
108+
url := fmt.Sprintf("%s/users/change-password", c.BaseURL)
109109
body, err := json.Marshal(reqBody)
110110
if err != nil {
111111
return fmt.Errorf("failed to marshal request: %w", err)
@@ -130,7 +130,7 @@ func (c *Client) ChangeOwnPassword(reqBody UserChangePasswordRequest) error {
130130

131131
// ListUsers calls GET /users (admin only)
132132
func (c *Client) ListUsers() ([]map[string]interface{}, error) {
133-
url := fmt.Sprintf("%s/api/users", c.BaseURL)
133+
url := fmt.Sprintf("%s/users", c.BaseURL)
134134
request, err := http.NewRequest("GET", url, nil)
135135
if err != nil {
136136
return nil, fmt.Errorf("failed to create request: %w", err)

0 commit comments

Comments
 (0)