Skip to content

feat: add management-api #145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions management-api/client/cachestorage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package client

// CacheStorage defines the interface for cache storage
type CacheStorage interface {
// Get retrieves the cached value
// if you need set prefix, please implement it in the implementation
Get(key string) (string, error)
// Setex sets a cache with expiration time
Setex(key string, value string, seconds int) error
}
164 changes: 164 additions & 0 deletions management-api/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package client

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"

"github.com/logto-io/go/management-api/logto"
"golang.org/x/oauth2"
)

// LogtoTokenSource implements oauth2.TokenSource interface
type LogtoTokenSource struct {
clientID string
clientSecret string
resourceURL string
cacheStorage CacheStorage
token *oauth2.Token
mutex sync.RWMutex
endpoint string
}

type tokenCache struct {
IssuedAt time.Time `json:"issued_at"`
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}

// Token implements oauth2.TokenSource interface
func (l *LogtoTokenSource) Token() (*oauth2.Token, error) {
if l.cacheStorage != nil {
l.mutex.RLock()
tokenCacheStr, err := l.cacheStorage.Get(l.clientID)
if err != nil {
l.mutex.RUnlock()
return nil, fmt.Errorf("get access token from cache failed: %v", err)
}

if tokenCacheStr != "" {
var tokenCache tokenCache
if err := json.Unmarshal([]byte(tokenCacheStr), &tokenCache); err != nil {
l.mutex.RUnlock()
return nil, fmt.Errorf("decode access token from cache failed: %v", err)
}

if tokenCache.IssuedAt.Add(time.Duration(tokenCache.ExpiresIn) * time.Second).After(time.Now()) {
l.token = &oauth2.Token{
AccessToken: tokenCache.AccessToken,
TokenType: tokenCache.TokenType,
Expiry: tokenCache.IssuedAt.Add(time.Duration(tokenCache.ExpiresIn) * time.Second),
}
l.mutex.RUnlock()
return l.token, nil
}
}
l.mutex.RUnlock()
} else {
l.mutex.RLock()
if l.token != nil && l.token.Valid() {
defer l.mutex.RUnlock()
return l.token, nil
}
l.mutex.RUnlock()
}

l.mutex.Lock()
defer l.mutex.Unlock()

// Request new token
values := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{l.clientID},
"client_secret": []string{l.clientSecret},
"resource": []string{l.resourceURL},
"scope": []string{"all"},
}

resp, err := http.PostForm(l.endpoint+"/oidc/token", values)
if err != nil {
return nil, fmt.Errorf("get access token failed: %v", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read access token response failed: %v", err)
}

var tokenResp tokenCache
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return nil, fmt.Errorf("decode access token response failed: %v", err)
}

if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("get access token failed: %s", string(respBody))
}

if l.cacheStorage != nil {
tokenResp.IssuedAt = time.Now()
tokenCacheStr, err := json.Marshal(tokenResp)
if err != nil {
return nil, fmt.Errorf("encode access token to cache failed: %v", err)
}
l.cacheStorage.Setex(l.clientID, string(tokenCacheStr), int(tokenResp.ExpiresIn))
}

l.token = &oauth2.Token{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
Expiry: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
}

return l.token, nil
}

type Client struct {
*logto.APIClient
openapiConfig *logto.Configuration
Context context.Context
}

func NewClient(config *Config, cache CacheStorage) *Client {
urlInfo, err := url.Parse(config.Endpoint)
if err != nil {
panic(fmt.Sprintf("invalid endpoint: %v", err))
}

openapiConfig := logto.NewConfiguration()
openapiConfig.Scheme = urlInfo.Scheme
openapiConfig.Host = urlInfo.Host
openapiConfig.Debug = config.Debug
openapiConfig.Servers = []logto.ServerConfiguration{
{
URL: config.Endpoint,
},
}

if config.ManagementApiResourceURL == "" {
config.ManagementApiResourceURL = "https://default.logto.app/api"
}

tokenSource := &LogtoTokenSource{
clientID: config.AppId,
clientSecret: config.AppSecret,
cacheStorage: cache,
endpoint: config.Endpoint,
resourceURL: config.ManagementApiResourceURL,
}

client := &Client{
openapiConfig: openapiConfig,
APIClient: logto.NewAPIClient(openapiConfig),
Context: context.WithValue(context.Background(), logto.ContextOAuth2, tokenSource),
}

return client
}
23 changes: 23 additions & 0 deletions management-api/client/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package client

type Config struct {
// Endpoint is the Logto endpoint
// caution:For Logto Cloud users: when you’re interacting with Logto Management API, you can not use custom domain,
// use the default Logto endpoint https://{your_tenant_id}.logto.app
Endpoint string

// ManagementApiResourceURL is the URL of the resource you want to access, default is https://default.logto.app/api
// caution:For Logto Cloud users: when you’re interacting with Logto Management API, you can not use custom domain,
// use the default Logto endpoint https://{your_tenant_id}.logto.app/api
// make sure your m2m app has the management api role to access this resource
ManagementApiResourceURL string

// AppId is the client id
AppId string

// AppSecret is the client secret
AppSecret string

// Debug is the debug mode
Debug bool
}
54 changes: 54 additions & 0 deletions management-api/examples/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"encoding/json"
"fmt"
"log"

"github.com/logto-io/go/management-api/client"
"github.com/logto-io/go/management-api/logto"
)

const (
endpoint = "http://localhost:3001"
appId = "<app_id>"
appSecret = "<app_secret>"
testUserId = "<user_id>"
)

func main() {
client := client.NewClient(&client.Config{
AppId: appId,
AppSecret: appSecret,
Endpoint: endpoint,
Debug: true,
}, nil)

// Example: Get user information
req := client.UsersAPI.GetUser(client.Context, testUserId)
result, resp, err := req.Execute()
if err != nil {
log.Fatalf("API call failed: %v", err)
}

fmt.Printf("Response status code: %d\n", resp.StatusCode)
jsonBody, _ := json.MarshalIndent(result, "", " ")
fmt.Printf("Response body: %s\n", string(jsonBody))

{
// Update user email
newEmail := fmt.Sprintf("%s@change.me", result.Id)

req := client.UsersAPI.UpdateUser(client.Context, testUserId)
var updateUserRequestPayload logto.UpdateUserRequest
updateUserRequestPayload.SetPrimaryEmail(logto.UpdateUserRequestPrimaryEmail{String: &newEmail})
req = req.UpdateUserRequest(updateUserRequestPayload)
result, resp, err := req.Execute()
if err != nil {
log.Fatalf("API call failed: %v", err)
}
fmt.Printf("Response status code: %d\n", resp.StatusCode)
jsonBody, _ := json.MarshalIndent(result, "", " ")
fmt.Printf("Response body: %s\n", string(jsonBody))
}
}
31 changes: 31 additions & 0 deletions management-api/fix_vars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import re
import os


def fix_file(file_path):
with open(file_path, "r") as f:
content = f.read()

# Fix pattern: var xxx Type = {}
pattern1 = r"var\s+([A-Za-z0-9_]+)\s+([A-Za-z0-9_]+)\s*=\s*{}"
content = re.sub(pattern1, r"var \1 \2", content)

# Fix pattern: var xxx Type = false
pattern2 = r"var\s+([A-Za-z0-9_]+)\s+([A-Za-z0-9_]+)\s*=\s*false"
content = re.sub(pattern2, r"var \1 \2", content)

with open(file_path, "w") as f:
f.write(content)


def main():
for root, _, files in os.walk("."):
for file in files:
if file.endswith(".go"):
file_path = os.path.join(root, file)
fix_file(file_path)
print("Fix openapi vars done.")


if __name__ == "__main__":
main()
11 changes: 11 additions & 0 deletions management-api/gen_sdk.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash

PKGNAME=logto
LOGOTO_ENDPOINT=https://default.logto.app
rm -rf ${PKGNAME}
openapi-generator generate -i ${LOGOTO_ENDPOINT}/api/swagger.json \
-g go -o ./${PKGNAME} \
--git-user-id logto-io --git-repo-id go/management-api/${PKGNAME} \
--additional-properties=packageName=${PKGNAME},withGoMod=false
python3 fix_vars.py
go mod tidy
19 changes: 19 additions & 0 deletions management-api/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module github.com/logto-io/go/management-api

go 1.22.6

require (
github.com/stretchr/testify v1.4.0
golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558
gopkg.in/validator.v2 v2.0.1
)

require (
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/golang/protobuf v1.4.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
google.golang.org/appengine v1.6.6 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)
Loading