Skip to content

Commit

Permalink
Refactoring
Browse files Browse the repository at this point in the history
  • Loading branch information
kafran committed Nov 13, 2024
1 parent 1888efd commit 7b9659c
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 99 deletions.
File renamed without changes.
255 changes: 159 additions & 96 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

//go:embed static template embed.html
//go:embed static template
var files embed.FS

// Retorno da API do Power BI
Expand Down Expand Up @@ -53,11 +53,27 @@ type EmbedData struct {
EmbedURL string `json:"embedUrl"`
}

// For security reasons, the lifetime of the embed token is set to the
// remaining lifetime of the Microsoft Entra token used to call the
// GenerateToken API. Therefore, if you use the same Microsoft Entra
// token to generate several embed tokens, the lifetime of the
// generated embed tokens will be shorter with each call.
type TemplateData struct {
EmbedData EmbedData
Error string
}

// Optamos por fazer um cache do token de acesso do Entra ID. Ou seja, todos os
// clientes (requests) feitos à aplicação utilizarão o mesmo token e portanto o
// tempo de vida do token (aproximadamente de 1h) será compartilhado. É preciso
// estar ciente das implicações que isso pode trazer, uma vez que o tempo de
// vida do token de acesso do Entra ID é compartilhado com o tempo de vida do
// embed token do Power BI. Isso pode trazer implicações principalmente na
// experiência do usuário que pode estar com um cliente carregado no navegador
// com um token perto de expirar, ainda que tenha recém carregado um painel.
// Daí a implementação de uma lógica no lado do cliente apenas para renovar o
// embed token, suportado pela própria API do Power BI.
// Da documentação do Power BI:
// Por razões de segurança, o tempo de vida do token incorporado é definido pelo
// tempo de vida restante do token Microsoft Entra usado para chamar a API
// GenerateToken. Portanto, se você usar o mesmo token Microsoft Entra para
// gerar vários tokens incorporados, o tempo de vida dos tokens incorporados
// gerados será menor a cada chamada.
// https://learn.microsoft.com/en-us/power-bi/developer/embedded/generate-embed-token#considerations-and-limitations
type EntraIdService struct {
token azcore.AccessToken
Expand All @@ -72,118 +88,165 @@ func (e *EntraIdService) getAccessToken(ctx context.Context) (string, error) {
return e.token.Token, nil
}

// azidentity por padrão pega as credenciais de acesso das variáveis de
// ambiente AZURE_CLIENT_SECRET, AZURE_TENANT_ID, AZURE_CLIENT_ID. Boa
// prática é carregar essas credenciais do Azure Key Vault como variáveis de
// ambiente no ambiente (VM, Container ou App Service) em que a aplicação
// está rodando.
credential, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return "", fmt.Errorf("failed to obtain a credential: %v", err)
log.Printf("failed to obtain a credential: %v", err)
return "", fmt.Errorf("failed to obtain a credential: %w", err)
}

token, err := credential.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://analysis.windows.net/powerbi/api/.default"}})
if err != nil {
return "", fmt.Errorf("failed to get a token: %v", err)
log.Printf("failed to get a token: %v", err)
return "", fmt.Errorf("failed to get a token: %w", err)
}

e.token = token
return token.Token, nil
}

func main() {
httpClient := &http.Client{Timeout: 30 * time.Second}
entraIdService := &EntraIdService{}
app := &App{
template: template.Must(template.ParseFS(files, "template/*.html")),
entraIdService: &EntraIdService{},
httpClient: &http.Client{Timeout: 30 * time.Second},
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(files))
mux.HandleFunc("GET /", app.handleRoot)
mux.HandleFunc("GET /w/{workspace}/r/{report}", app.handleEmbed)
err := http.ListenAndServe(":8080", mux)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
templ, err := template.ParseFS(files, "template/*.html")
if err != nil {
log.Fatalf("failed to parse the template: %v", err)
}
if err := templ.ExecuteTemplate(w, "index.html", nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})

mux.HandleFunc("GET /w/{workspace}/r/{report}", func(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "text/html")
token, err := entraIdService.getAccessToken(r.Context())
if err != nil {
log.Fatalf("failed to get a token: %v", err)
}

workspaceID := r.PathValue("workspace")
reportID := r.PathValue("report")
reportURL := fmt.Sprintf("https://api.powerbi.com/v1.0/myorg/groups/%s/reports/%s", workspaceID, reportID)

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reportURL, nil)
if err != nil {
log.Fatalf("failed to create a request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := httpClient.Do(req)
if err != nil {
log.Fatalf("failed to get a report: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Fatalf("unnexpected status: %v", res.Status)
}
var report ReportResponse = ReportResponse{}
if err := json.NewDecoder(res.Body).Decode(&report); err != nil {
log.Fatalf("failed to decode the response: %v", err)
}
// Encapsula toda a lógica da aplicação e suas dependências
type App struct {
template *template.Template
entraIdService *EntraIdService
httpClient *http.Client
}

embedTokenURL := "https://api.powerbi.com/v1.0/myorg/GenerateToken"
embedTokenReq := EmbedTokenRequest{
Datasets: []map[string]string{{"id": report.DatasetID}},
Reports: []map[string]string{{"id": report.ReportID}},
TargetWorkspaces: []map[string]string{{"id": workspaceID}},
}
embedTokenReqJSON, err := json.Marshal(embedTokenReq)
if err != nil {
log.Fatalf("failed to marshal the request: %v", err)
}
req, err = http.NewRequestWithContext(r.Context(), http.MethodPost, embedTokenURL, bytes.NewReader(embedTokenReqJSON))
if err != nil {
log.Fatalf("failed to create a request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err = httpClient.Do(req)
if err != nil {
log.Fatalf("failed to get an embed token: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Fatalf("unnexpected status: %v", res.Status)
}
var embedToken EmbedTokenResponse = EmbedTokenResponse{}
if err := json.NewDecoder(res.Body).Decode(&embedToken); err != nil {
log.Fatalf("failed to decode the response: %v", err)
}
func (app *App) handleRoot(w http.ResponseWriter, r *http.Request) {
if err := app.template.ExecuteTemplate(w, "index.html", nil); err != nil {
log.Printf("handleRoot failed to execute template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

embedData := EmbedData{
TokenId: embedToken.TokenId,
AccessToken: embedToken.Token,
TokenExpiry: embedToken.Expiration,
EmbedURL: report.EmbedURL,
}
_ = embedData
templ, err := template.ParseFS(files, "template/*.html")
if err != nil {
log.Fatalf("failed to parse the template: %v", err)
func (app *App) handleEmbed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
ctx := r.Context()
workspaceID := r.PathValue("workspace")
reportID := r.PathValue("report")
report, err := app.fetchReport(ctx, workspaceID, reportID)
if err != nil {
log.Printf("handleEmbed failed to fetchReport %v", err)
templateData := TemplateData{Error: err.Error()}
if err := app.template.ExecuteTemplate(w, "index.html", templateData); err != nil {
log.Printf("handleEmbed failed to execute template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
if err := templ.ExecuteTemplate(w, "embed.html", embedData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
embedToken, err := app.fetchEmbedToken(ctx, report, workspaceID)
if err != nil {
log.Printf("handleEmbed failed to fetchEmbedToken %v", err)
templateData := TemplateData{Error: err.Error()}
if err := app.template.ExecuteTemplate(w, "index.html", templateData); err != nil {
log.Printf("handleEmbed failed to execute template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
embedData := EmbedData{
TokenId: embedToken.TokenId,
AccessToken: embedToken.Token,
TokenExpiry: embedToken.Expiration,
EmbedURL: report.EmbedURL,
}
templateData := TemplateData{EmbedData: embedData}
if err := app.template.ExecuteTemplate(w, "embed.html", templateData); err != nil {
log.Printf("handleEmbed failed to execute template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

// if err := json.NewEncoder(w).Encode(embedData); err != nil {
// http.Error(w, err.Error(), http.StatusInternalServerError)
// }
})
func (app *App) fetchReport(ctx context.Context, workspaceID, reportID string) (ReportResponse, error) {
reportURL := fmt.Sprintf("https://api.powerbi.com/v1.0/myorg/groups/%s/reports/%s", workspaceID, reportID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reportURL, nil)
if err != nil {
log.Printf("fetchReport failed to create report request: %v", err)
return ReportResponse{}, fmt.Errorf("failed to create report request: %v", err)
}
token, err := app.entraIdService.getAccessToken(ctx)
if err != nil {
log.Printf("fetchReport failed to get access token: %v", err)
return ReportResponse{}, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := app.httpClient.Do(req)
if err != nil {
log.Printf("fetchReport failed to fetch report: %v", err)
return ReportResponse{}, fmt.Errorf("failed to fetch report: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Printf("fetchReport unexpected status: %v", res.Status)
return ReportResponse{}, fmt.Errorf("unexpected status when fetching report: %s", res.Status)
}
var report ReportResponse
if err := json.NewDecoder(res.Body).Decode(&report); err != nil {
log.Printf("fetchReport failed to decode report response: %v", err)
return ReportResponse{}, fmt.Errorf("failed to decode report response: %w", err)
}
return report, nil
}

err := http.ListenAndServe(":8080", mux)
func (app *App) fetchEmbedToken(ctx context.Context, report ReportResponse, workspaceID string) (EmbedTokenResponse, error) {
embedTokenURL := "https://api.powerbi.com/v1.0/myorg/GenerateToken"
embedTokenReq := EmbedTokenRequest{
Datasets: []map[string]string{{"id": report.DatasetID}},
Reports: []map[string]string{{"id": report.ReportID}},
TargetWorkspaces: []map[string]string{{"id": workspaceID}},
}
embedTokenReqJSON, err := json.Marshal(embedTokenReq)
if err != nil {
log.Fatal("ListenAndServe: ", err)
log.Printf("fetchEmbedToken failed to marshal embed token request: %v", err)
return EmbedTokenResponse{}, fmt.Errorf("failed to marshal embed token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, embedTokenURL, bytes.NewReader(embedTokenReqJSON))
if err != nil {
log.Printf("fetchEmbedToken failed to create embed token request: %v", err)
return EmbedTokenResponse{}, fmt.Errorf("failed to create embed token request: %w", err)
}
token, err := app.entraIdService.getAccessToken(ctx)
if err != nil {
log.Printf("fetchEmbedToken failed to get access token: %v", err)
return EmbedTokenResponse{}, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := app.httpClient.Do(req)
if err != nil {
log.Printf("fetchEmbedToken failed to fetch embed token: %v", err)
return EmbedTokenResponse{}, fmt.Errorf("failed to fetch embed token: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Printf("fetchEmbedToken unexpected status: %v", res.Status)
return EmbedTokenResponse{}, fmt.Errorf("unexpected status when fetching embed token: %s", res.Status)
}
var embedToken EmbedTokenResponse
if err := json.NewDecoder(res.Body).Decode(&embedToken); err != nil {
log.Printf("fetchEmbedToken failed to decode embed token response: %v", err)
return EmbedTokenResponse{}, fmt.Errorf("failed to decode embed token response: %w", err)
}
return embedToken, nil
}
31 changes: 28 additions & 3 deletions app/template/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{{ if .EmbedURL}}
{{ if and .EmbedData.AccessToken .EmbedData.EmbedURL }}
<script src="https://code.jquery.com/jquery-3.7.1.slim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/powerbi-client/2.23.1/powerbi.min.js" integrity="sha512-30PYMgFkaF5Ir4s3i+/APAhp5aUi9nXKFSgOMPQ3LttPyYp79CzUaP7lvYGh6fKpOCBCOoD/RKoJtY5Iv8HT4g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
Expand All @@ -20,8 +20,8 @@
var reportLoadConfig = {
type: "report",
tokenType: models.TokenType.Embed,
accessToken: "{{.AccessToken}}",
embedUrl: "{{.EmbedURL}}",
accessToken: "{{.EmbedData.AccessToken}}",
embedUrl: "{{.EmbedData.EmbedURL}}",
};

var report = powerbi.embed(reportContainer, reportLoadConfig);
Expand All @@ -48,6 +48,31 @@
});
});
</script>
{{ else if .Error }}
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="liveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false"><rect width="100%" height="100%" fill="#007aff"/></svg>
<strong class="me-auto">Error</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<p>{{ .Error }}</p>
<p>Verifique a URL (workspace id/report id) ou atualize a página.</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
var toastEl = document.getElementById('liveToast');
if (toastEl) {
var toast = new bootstrap.Toast(toastEl, {
autohide: false
});
toast.show();
}
});
</script>
{{ end }}
</body>
</html>
Expand Down

0 comments on commit 7b9659c

Please sign in to comment.