adding allow_unsafe_endpoints config option.

adding flattend resource graph.
adding Reciprocal resources
adding a report labs page.
This commit is contained in:
Jason Kulatunga 2022-12-21 19:51:02 -08:00
parent f8aac49524
commit 918c856338
25 changed files with 659 additions and 117 deletions

View File

@ -24,6 +24,8 @@ func (c *configuration) Init() error {
c.SetDefault("web.listen.port", "8080")
c.SetDefault("web.listen.host", "0.0.0.0")
c.SetDefault("web.listen.basepath", "")
c.SetDefault("web.allow_unsafe_endpoints", false)
c.SetDefault("web.src.frontend.path", "/opt/fasten/web")
c.SetDefault("database.location", "/opt/fasten/db/fasten.db") //TODO: should be /opt/fasten/fasten.db

View File

@ -24,6 +24,7 @@ type DatabaseRepository interface {
GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error
RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error
GetFlattenedResourceGraph(ctx context.Context) ([]*models.ResourceFhir, []*models.ResourceFhir, error)
//UpsertProfile(context.Context, *models.Profile) error
//UpsertOrganziation(context.Context, *models.Organization) error

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/dominikbraun/graph"
sourceModel "github.com/fastenhealth/fasten-sources/clients/models"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
@ -11,6 +12,7 @@ import (
"github.com/glebarez/sqlite"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"gorm.io/datatypes"
"gorm.io/gorm"
"net/url"
@ -202,13 +204,11 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent
//note: we create the association in the related_resources table **before** the model actually exists.
if rawResource.ReferencedResources != nil {
//these are resources that are referenced by the current resource
relatedResources := []*models.ResourceFhir{}
//reciprocalRelatedResources := []*models.ResourceFhir{}
for _, referencedResource := range rawResource.ReferencedResources {
parts := strings.Split(referencedResource, "/")
if len(parts) == 2 {
if len(parts) != 2 {
continue
}
relatedResource := &models.ResourceFhir{
OriginBase: models.OriginBase{
@ -218,23 +218,11 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent
},
RelatedResourceFhir: nil,
}
relatedResources = append(relatedResources, relatedResource)
//if the related resource is an Encounter or Condition, make sure we create a reciprocal association as well, just incase
if parts[0] == "Condition" || parts[0] == "Encounter" {
//manually create association (we've tried to create using Association.Append, and it doesnt work for some reason.
err := sr.AddResourceAssociation(ctx, &source, parts[0], parts[1], &source, wrappedResourceModel.SourceResourceType, wrappedResourceModel.SourceResourceID)
err := sr.AddReciprocalResourceAssociations(ctx, &source, wrappedResourceModel, &source, relatedResource)
if err != nil {
sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err)
}
}
}
}
//ignore errors when creating associations (we always get a 'WHERE conditions required' error, )
sr.GormClient.WithContext(ctx).Model(wrappedResourceModel).Association("RelatedResourceFhir").Append(relatedResources)
}
sr.Logger.Infof("insert/update (%v) %v", rawResource.SourceResourceType, rawResource.SourceResourceID)
@ -322,7 +310,7 @@ func (sr *SqliteRepository) ListResources(ctx context.Context, queryOptions mode
queryBuilder := sr.GormClient.WithContext(ctx)
if queryOptions.PreloadRelated {
//enable preload functionality in query
queryBuilder = queryBuilder.Preload("RelatedResourceFhir").Preload("RelatedResourceFhir.RelatedResourceFhir")
queryBuilder = queryBuilder.Preload("RelatedResourceFhir")
}
results := queryBuilder.Where(queryParam).
Find(&wrappedResourceModels)
@ -392,11 +380,159 @@ func (sr *SqliteRepository) GetPatientForSources(ctx context.Context) ([]models.
return wrappedResourceModels, results.Error
}
// Retrieve a list of all fhir resources (vertex), and a list of all associations (edge)
// Generate a graph
// return list of root nodes, and their flattened related resources.
func (sr *SqliteRepository) GetFlattenedResourceGraph(ctx context.Context) ([]*models.ResourceFhir, []*models.ResourceFhir, error) {
// Get list of all resources
wrappedResourceModels, err := sr.ListResources(ctx, models.ListResourceQueryOptions{})
if err != nil {
return nil, nil, err
}
// Get list of all relationships
var relatedResourceRelationships []models.RelatedResource
// SELECT * FROM related_resources WHERE user_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9";
result := sr.GormClient.WithContext(ctx).
Table("related_resources").
Where(models.RelatedResource{
ResourceFhirUserID: sr.GetCurrentUser(ctx).ID,
}).
Scan(&relatedResourceRelationships)
if result.Error != nil {
return nil, nil, result.Error
}
//Generate Graph
g := graph.New(resourceVertexId, graph.Directed(), graph.Acyclic(), graph.Rooted())
//add vertices to the graph (must be done first)
for ndx, _ := range wrappedResourceModels {
err = g.AddVertex(
&wrappedResourceModels[ndx],
)
if err != nil {
return nil, nil, fmt.Errorf("an error occurred while adding vertex: %v", err)
}
}
//add edges to graph
for _, relationship := range relatedResourceRelationships {
err = g.AddEdge(
resourceKeysVertexId(relationship.ResourceFhirSourceID.String(), relationship.ResourceFhirSourceResourceType, relationship.ResourceFhirSourceResourceID),
resourceKeysVertexId(relationship.RelatedResourceFhirSourceID.String(), relationship.RelatedResourceFhirSourceResourceType, relationship.RelatedResourceFhirSourceResourceID),
)
if err != nil {
//this may occur because vertices may not exist
sr.Logger.Warnf("ignoring, an error occurred while adding edge: %v", err)
}
}
//// simplify graph if possible.
//graph.TransitiveReduction(g)
// AdjacencyMap computes and returns an adjacency map containing all vertices in the graph.
//
// There is an entry for each vertex, and each of those entries is another map whose keys are
// the hash values of the adjacent vertices. The value is an Edge instance that stores the
// source and target hash values (these are the same as the map keys) as well as edge metadata.
// map[string]map[string]Edge[string]{
// "A": map[string]Edge[string]{
// "B": {Source: "A", Target: "B"}
// "C": {Source: "A", Target: "C"}
// }
// }
adjacencyMap, err := g.AdjacencyMap()
if err != nil {
return nil, nil, fmt.Errorf("error while generating AdjacencyMap: %v", err)
}
// For a directed graph, PredecessorMap is the complement of AdjacencyMap. This is because in a directed graph, only
// vertices joined by an outgoing edge are considered adjacent to the current vertex, whereas
// predecessors are the vertices joined by an ingoing edge.
// ie. "empty" verticies in this map are "root" nodes.
predecessorMap, err := g.PredecessorMap()
if err != nil {
return nil, nil, fmt.Errorf("error while generating PredecessorMap: %v", err)
}
// Doing this in one massive function, because passing graph by reference is difficult due to generics.
// Step 1: use predecessorMap to find all "root" encounters and conditions. store those nodes in their respective lists.
encounterList := []*models.ResourceFhir{}
conditionList := []*models.ResourceFhir{}
for vertexId, val := range predecessorMap {
if len(val) != 0 {
//skip any nodes/verticies/resources that are not "root"
continue
}
resource, err := g.Vertex(vertexId)
if err != nil {
//could not find this vertex in graph, ignoring
continue
}
if strings.ToLower(resource.SourceResourceType) == "condition" {
conditionList = append(conditionList, resource)
} else if strings.ToLower(resource.SourceResourceType) == "encounter" {
encounterList = append(encounterList, resource)
}
}
// Step 2: define a function. When given a resource, should find all related resources, flatten the heirarchy and set the RelatedResourceFhir list
flattenRelatedResourcesFn := func(resource *models.ResourceFhir) {
// this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list
vertexId := resourceVertexId(resource)
sr.Logger.Debugf("populating resource: %s", vertexId)
resource.RelatedResourceFhir = []*models.ResourceFhir{}
//get all the resources associated with this node
graph.DFS(g, vertexId, func(relatedVertexId string) bool {
relatedResourceFhir, _ := g.Vertex(relatedVertexId)
//skip the current resource if it's referenced in this list.
if vertexId != resourceVertexId(relatedResourceFhir) {
resource.RelatedResourceFhir = append(resource.RelatedResourceFhir, relatedResourceFhir)
}
return false
})
}
// Step 3: populate related resources for each encounter, flattened
for ndx, _ := range encounterList {
// this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list
flattenRelatedResourcesFn(encounterList[ndx])
}
// Step 4: find all encounters referenced by the root conditions, populate them, then add them to the condition as RelatedResourceFhir
for ndx, _ := range conditionList {
// this is a "root" condition,
conditionList[ndx].RelatedResourceFhir = []*models.ResourceFhir{}
vertexId := resourceVertexId(conditionList[ndx])
for relatedVertexId, _ := range adjacencyMap[vertexId] {
relatedResourceFhir, _ := g.Vertex(relatedVertexId)
flattenRelatedResourcesFn(relatedResourceFhir)
conditionList[ndx].RelatedResourceFhir = append(conditionList[ndx].RelatedResourceFhir, relatedResourceFhir)
}
}
//TODO: sort conditionList by date
return conditionList, encounterList, nil
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Resource Associations
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error {
//edges are always "strongly connected", however "source" nodes (roots, like Condition or Encounter) are only one way.
//add an edge from every resource to its related resource. Keep in mind that FHIR resources may not contain reciprocal edges, so we ensure the graph is rooted by flipping any
//related resources that are "Condition" or "Encounter"
func (sr *SqliteRepository) AddReciprocalResourceAssociations(ctx context.Context, source *models.SourceCredential, resource *models.ResourceFhir, relatedSource *models.SourceCredential, relatedResource *models.ResourceFhir) error {
//ensure that the sources are "owned" by the same user
if source.UserID != relatedSource.UserID {
@ -405,11 +541,111 @@ func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *
return fmt.Errorf("user id's must match current user")
}
//manually create association (we've tried to create using Association.Append, and it doesnt work for some reason.
//manually create association(s) (we've tried to create using Association.Append, and it doesnt work for some reason.
if isResourceGraphSource(resource) && isResourceGraphSource(relatedResource) {
//handle the case where both resources are "sources" (Condition or Encounter)
if strings.ToLower(resource.SourceResourceType) == "condition" {
//condition is always the source
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else {
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
}
} else if isResourceGraphSource(resource) || isResourceGraphSink(relatedResource) {
//resource is a Source, or the relatedResource is a sink, create a "resource" => "relatedResource" edge
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else if isResourceGraphSource(relatedResource) || isResourceGraphSink(resource) {
//relatedResource is a Source, or the resource is a sink, create a "relatedResource" => "resource" edge
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else {
//this is a regular pair of resources, create reciprocal edges
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
}
return nil
}
func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error {
return sr.GormClient.WithContext(ctx).Table("related_resources").Create(map[string]interface{}{
"resource_fhir_user_id": source.UserID,
"resource_fhir_source_id": source.ID,
"resource_fhir_source_resource_type": resourceType,
"resource_fhir_source_resource_id": resourceId,
"related_resource_fhir_user_id": relatedSource.UserID,
"related_resource_fhir_source_id": relatedSource.ID,
"related_resource_fhir_source_resource_type": relatedResourceType,
"related_resource_fhir_source_resource_id": relatedResourceId,
@ -425,9 +661,11 @@ func (sr *SqliteRepository) RemoveResourceAssociation(ctx context.Context, sourc
//manually create association (we've tried to create using Association.Append, and it doesnt work for some reason.
return sr.GormClient.WithContext(ctx).Table("related_resources").Delete(map[string]interface{}{
"resource_fhir_user_id": source.UserID,
"resource_fhir_source_id": source.ID,
"resource_fhir_source_resource_type": resourceType,
"resource_fhir_source_resource_id": resourceId,
"related_resource_fhir_user_id": relatedSource.UserID,
"related_resource_fhir_source_id": relatedSource.ID,
"related_resource_fhir_source_resource_type": relatedResourceType,
"related_resource_fhir_source_resource_id": relatedResourceId,
@ -543,3 +781,23 @@ func sqlitePragmaString(pragmas map[string]string) string {
}
return ""
}
//source resource types are resources that are at the root of the graph, nothing may reference them directly
func isResourceGraphSource(resource *models.ResourceFhir) bool {
sourceResourceTypes := []string{"condition", "encounter"}
return slices.Contains(sourceResourceTypes, strings.ToLower(resource.SourceResourceType)) // true
}
//sink resource types are the leaves of the graph, they must not reference anything else. (only be referenced)
func isResourceGraphSink(resource *models.ResourceFhir) bool {
sinkResourceTypes := []string{"location", "binary", "device", "organization", "practitioner", "medication", "patient"}
return slices.Contains(sinkResourceTypes, strings.ToLower(resource.SourceResourceType)) // true
}
// helper function for GetResourceGraph, creating a "hash" for the resource
func resourceVertexId(resource *models.ResourceFhir) string {
return resourceKeysVertexId(resource.SourceID.String(), resource.SourceResourceType, resource.SourceResourceID)
}
func resourceKeysVertexId(sourceId string, resourceType string, resourceId string) string {
return strings.ToLower(fmt.Sprintf("%s/%s/%s", sourceId, resourceType, resourceId))
}

View File

@ -0,0 +1,16 @@
package models
import "github.com/google/uuid"
//this model is used by the DB (see ResourceAssociation for web model)
type RelatedResource struct {
ResourceFhirUserID uuid.UUID `gorm:"resource_fhir_user_id"`
ResourceFhirSourceID uuid.UUID `gorm:"resource_fhir_source_id"`
ResourceFhirSourceResourceType string `gorm:"resource_fhir_source_resource_type"`
ResourceFhirSourceResourceID string `gorm:"resource_fhir_source_resource_id"`
RelatedResourceFhirUserID uuid.UUID `gorm:"related_resource_fhir_user_id"`
RelatedResourceFhirSourceID uuid.UUID `gorm:"related_resource_fhir_source_id"`
RelatedResourceFhirSourceResourceType string `gorm:"related_resource_fhir_source_resource_type"`
RelatedResourceFhirSourceResourceID string `gorm:"related_resource_fhir_source_resource_id"`
}

View File

@ -1,5 +1,6 @@
package models
//this model is used by the Webserver (see RelatedResource for db model)
type ResourceAssociation struct {
SourceID string `json:"source_id"`
SourceResourceType string `json:"source_resource_type"`

View File

@ -11,7 +11,7 @@ type ResourceFhir struct {
ResourceRaw datatypes.JSON `json:"resource_raw" gorm:"resource_raw"`
//relationships
RelatedResourceFhir []*ResourceFhir `json:"related_resources" gorm:"many2many:related_resources;ForeignKey:source_id,source_resource_type,source_resource_id;references:source_id,source_resource_type,source_resource_id;"`
RelatedResourceFhir []*ResourceFhir `json:"related_resources" gorm:"many2many:related_resources;ForeignKey:user_id,source_id,source_resource_type,source_resource_id;references:user_id,source_id,source_resource_type,source_resource_id;"`
}
type ListResourceQueryOptions struct {

View File

@ -107,3 +107,25 @@ func ReplaceResourceAssociation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GetResourceFhirGraph
// Retrieve a list of all fhir resources (vertex), and a list of all associations (edge)
// Generate a graph
// find the PredecessorMap
// - filter to only vertices that are "Condition" or "Encounter" and are "root" nodes (have no edges directed to this node)
func GetResourceFhirGraph(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
conditionResourceList, encounterResourceList, err := databaseRepo.GetFlattenedResourceGraph(c)
if err != nil {
logger.Errorln("An error occurred while retrieving list of resources", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": map[string][]*models.ResourceFhir{
"Condition": conditionResourceList,
"Encounter": encounterResourceList,
}})
}

View File

@ -12,8 +12,6 @@ import (
"github.com/sirupsen/logrus"
"io/ioutil"
"net/http"
"net/url"
"strings"
)
func CreateSource(c *gin.Context) {
@ -179,64 +177,6 @@ func ListSource(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": sourceCreds})
}
func RawRequestSource(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
//!!!!!!INSECURE!!!!!!S
//We're setting the username to a user provided value, this is insecure, but required for calling databaseRepo fns
c.Set("AUTH_USERNAME", c.Param("username"))
foundSource, err := databaseRepo.GetSource(c, c.Param("sourceId"))
if err != nil {
logger.Errorf("An error occurred while finding source credential: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
if foundSource == nil {
logger.Errorf("Did not source credentials for %s", c.Param("sourceType"))
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()})
return
}
client, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), foundSource.SourceType, c, logger, foundSource)
if err != nil {
logger.Errorf("Could not initialize source client %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//TODO: if source has been updated, we should save the access/refresh token.
if updatedSource != nil {
logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource)
// err := databaseRepo.CreateSource(c, updatedSource)
// if err != nil {
// logger.Errorf("An error occurred while updating source credential %v", err)
// c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
// return
// }
}
var resp map[string]interface{}
parsedUrl, err := url.Parse(strings.TrimSuffix(c.Param("path"), "/"))
if err != nil {
logger.Errorf("Error parsing request, %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//make sure we include all query string parameters with the raw request.
parsedUrl.RawQuery = c.Request.URL.Query().Encode()
err = client.GetRequest(parsedUrl.String(), &resp)
if err != nil {
logger.Errorf("Error making raw request, %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error(), "data": resp})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func SyncSourceResources(c context.Context, logger *logrus.Entry, databaseRepo database.DatabaseRepository, sourceCred models.SourceCredential) (sourceModels.UpsertSummary, error) {
// after creating the source, we should do a bulk import
sourceClient, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), sourceCred.SourceType, c, logger, sourceCred)

View File

@ -0,0 +1,81 @@
package handler
import (
"github.com/fastenhealth/fasten-sources/clients/factory"
sourcePkg "github.com/fastenhealth/fasten-sources/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
"net/url"
"strings"
)
/*
These Endpoints are only available when Fasten is deployed with allow_unsafe_endpoints enabled.
*/
func UnsafeRequestSource(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
appConfig := c.MustGet("CONFIG").(config.Interface)
databaseRepo := c.MustGet("REPOSITORY").(database.DatabaseRepository)
//safety check incase this function is called in another way
if !appConfig.GetBool("web.allow_unsafe_endpoints") {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false})
return
}
//!!!!!!INSECURE!!!!!!S
//We're setting the username to a user provided value, this is insecure, but required for calling databaseRepo fns
c.Set("AUTH_USERNAME", c.Param("username"))
foundSource, err := databaseRepo.GetSource(c, c.Param("sourceId"))
if err != nil {
logger.Errorf("An error occurred while finding source credential: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
if foundSource == nil {
logger.Errorf("Did not source credentials for %s", c.Param("sourceType"))
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()})
return
}
client, updatedSource, err := factory.GetSourceClient(sourcePkg.GetFastenEnv(), foundSource.SourceType, c, logger, foundSource)
if err != nil {
logger.Errorf("Could not initialize source client %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//TODO: if source has been updated, we should save the access/refresh token.
if updatedSource != nil {
logger.Warnf("TODO: source credential has been updated, we should store it in the database: %v", updatedSource)
// err := databaseRepo.CreateSource(c, updatedSource)
// if err != nil {
// logger.Errorf("An error occurred while updating source credential %v", err)
// c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
// return
// }
}
var resp map[string]interface{}
parsedUrl, err := url.Parse(strings.TrimSuffix(c.Param("path"), "/"))
if err != nil {
logger.Errorf("Error parsing request, %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
//make sure we include all query string parameters with the raw request.
parsedUrl.RawQuery = c.Request.URL.Query().Encode()
err = client.GetRequest(parsedUrl.String(), &resp)
if err != nil {
logger.Errorf("Error making raw request, %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error(), "data": resp})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}

View File

@ -57,17 +57,31 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
secure.GET("/source/:sourceId", handler.GetSource)
secure.POST("/source/:sourceId/sync", handler.SourceSync)
secure.GET("/source/:sourceId/summary", handler.GetSourceSummary)
secure.GET("/resource/fhir", handler.ListResourceFhir) //
secure.GET("/resource/fhir", handler.ListResourceFhir)
secure.GET("/resource/graph", handler.GetResourceFhirGraph)
secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir)
secure.POST("/resource/association", handler.ReplaceResourceAssociation)
}
if ae.Config.GetString("log.level") == "DEBUG" {
//in debug mode, this endpoint lets us request data directly from the source api
ae.Logger.Warningf("***INSECURE*** ***INSECURE*** DEBUG mode enables developer functionality, including unauthenticated raw api requests")
if ae.Config.GetBool("web.allow_unsafe_endpoints") {
//this endpoint lets us request data directly from the source api
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningf("\"web.allow_unsafe_endpoints\" mode enabled!! This enables developer functionality, including unauthenticated raw api requests")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
ae.Logger.Warningln("***UNSAFE***")
unsafe := api.Group("/unsafe")
{
//http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992
api.GET("/raw/:username/:sourceId/*path", handler.RawRequestSource)
unsafe.GET("/:username/:sourceId/*path", handler.UnsafeRequestSource)
}
}
}
}

View File

@ -11,6 +11,7 @@ import {IsAuthenticatedAuthGuard} from './auth-guards/is-authenticated-auth-guar
import {SourceDetailComponent} from './pages/source-detail/source-detail.component';
import {PatientProfileComponent} from './pages/patient-profile/patient-profile.component';
import {MedicalHistoryComponent} from './pages/medical-history/medical-history.component';
import {ReportLabsComponent} from './pages/report-labs/report-labs.component';
const routes: Routes = [
@ -29,6 +30,7 @@ const routes: Routes = [
{ path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'medical-history', component: MedicalHistoryComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] },
// { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) },
// { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },

View File

@ -28,6 +28,7 @@ import { MomentModule } from 'ngx-moment';
import {AuthService} from './services/auth.service';
import { PatientProfileComponent } from './pages/patient-profile/patient-profile.component';
import { MedicalHistoryComponent } from './pages/medical-history/medical-history.component';
import { ReportLabsComponent } from './pages/report-labs/report-labs.component';
@NgModule({
@ -43,6 +44,7 @@ import { MedicalHistoryComponent } from './pages/medical-history/medical-history
SourceDetailComponent,
PatientProfileComponent,
MedicalHistoryComponent,
ReportLabsComponent,
],
imports: [
FormsModule,

View File

@ -0,0 +1,56 @@
<div class="card card-dashboard-seven mb-3">
<div class="card-header tx-medium">
<div class="row" >
<!-- Condition Header -->
<div class="col-6">
{{observationTitle}}
</div>
<div class="col-6">
{{observations[0] | fhirPath: "Observation.effectiveDateTime": "Observation.issued" | date}}
</div>
</div>
</div><!-- card-header -->
<div class="card-body">
<div class="row">
<!-- Condition Details -->
<div class="col-6 mb-2">
<div class="row pl-3">
<div class="col-12 mt-3 mb-2">
<p>
<strong>Test Code (loinc):</strong> {{observationCode}} <br/>
<strong>Latest Test Date:</strong> {{observations[0] | fhirPath: "Observation.effectiveDateTime": "Observation.issued" | date}} <br/>
<strong>Ordered By:</strong> {{observations[0] | fhirPath: "Observation.encounter.display"}} <br/>
<strong>Notes:</strong>
</p>
</div>
</div>
</div>
<div class="col-6 bg-gray-100">
<div class="row">
<p>
<small>
Troponin I is a part of the troponin complex. It binds to actin in thin myofilaments to hold the actin-tropomyosin complex in place. Because of it myosin cannot bind actin in relaxed muscle. When calcium binds to the Troponin C it causes conformational changes which lead to dislocation of troponin I and finally tropomyosin leaves the binding site for myosin on actin leading to contraction of muscle. The letter I is given due to its inhibitory character.
</small>
</p>
<ng-container *ngFor="let observation of observations">
<div routerLink="/source/{{observation?.source_id}}/resource/{{observation?.source_resource_id}}" class="col-6 mt-3 mb-2 tx-indigo">
<strong>{{observation | fhirPath: "Observation.valueQuantity.value" }}</strong>
</div>
</ng-container>
</div>
</div>
</div>
</div><!-- card-body -->
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReportLabsObservationComponent } from './report-labs-observation.component';
describe('ReportLabsObservationComponent', () => {
let component: ReportLabsObservationComponent;
let fixture: ComponentFixture<ReportLabsObservationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ReportLabsObservationComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ReportLabsObservationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import {Component, Input, OnInit} from '@angular/core';
import {ResourceFhir} from '../../models/fasten/resource_fhir';
@Component({
selector: 'app-report-labs-observation',
templateUrl: './report-labs-observation.component.html',
styleUrls: ['./report-labs-observation.component.scss']
})
export class ReportLabsObservationComponent implements OnInit {
@Input() observations: ResourceFhir[]
@Input() observationCode: string
@Input() observationTitle: string
constructor() { }
ngOnInit(): void {
}
}

View File

@ -40,6 +40,7 @@ import { ReportMedicalHistoryEditorComponent } from './report-medical-history-ed
import { TreeModule } from '@circlon/angular-tree-component';
import {FilterPipe} from '../pipes/filter.pipe';
import { ReportMedicalHistoryConditionComponent } from './report-medical-history-condition/report-medical-history-condition.component';
import { ReportLabsObservationComponent } from './report-labs-observation/report-labs-observation.component';
@NgModule({
imports: [
@ -86,6 +87,7 @@ import { ReportMedicalHistoryConditionComponent } from './report-medical-history
ReportMedicalHistoryEditorComponent,
FilterPipe,
ReportMedicalHistoryConditionComponent,
ReportLabsObservationComponent,
],
exports: [
ComponentsSidebarComponent,
@ -121,7 +123,8 @@ import { ReportMedicalHistoryConditionComponent } from './report-medical-history
ReportMedicalHistoryEditorComponent,
FhirPathPipe,
FilterPipe,
ReportMedicalHistoryConditionComponent
ReportMedicalHistoryConditionComponent,
ReportLabsObservationComponent
]
})

View File

@ -25,25 +25,15 @@ export class MedicalHistoryComponent implements OnInit {
ngOnInit(): void {
forkJoin([
this.fastenApi.getResources("Condition", null, null, true),
this.fastenApi.getResources("Encounter", null, null, true)
]).subscribe(results => {
this.conditions = results[0]
this.fastenApi.getResourceGraph().subscribe(results => {
this.conditions = results["Condition"]
this.unassigned_encounters = results["Encounter"]
//populate a lookup table with all resources
for(let condition of this.conditions){
this.recPopulateResourceLookup(condition)
}
console.log("Populated resource lookup:", this.resourceLookup);
//find unassigned encounters
console.log("all encounters:", results[1].length, results[1]);
(results[1] || []).map((encounter) => {
if(!this.resourceLookup[`${encounter.source_id}/${encounter.source_resource_type}/${encounter.source_resource_id}`]){
this.unassigned_encounters.push(encounter)
}
})
if(this.unassigned_encounters.length > 0){
console.log("Found mapping:", this.resourceLookup)

View File

@ -0,0 +1,26 @@
<div class="az-content">
<div class="container">
<div class="az-content-body">
<!-- Header Row -->
<report-header [reportHeaderTitle]="'Labs'"></report-header>
<!-- Observations Title -->
<div class="row mt-5 mb-3">
<div class="col-6">
<h1 class="az-dashboard-title">Observations</h1>
</div>
</div>
<!-- Observations List -->
<app-report-labs-observation *ngFor="let observationGroup of observationGroups | keyvalue"
[observations]="observationGroup.value"
[observationCode]="observationGroup.key"
[observationTitle]="observationGroupTitles[observationGroup.key]"
></app-report-labs-observation>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReportLabsComponent } from './report-labs.component';
describe('ReportLabsComponent', () => {
let component: ReportLabsComponent;
let fixture: ComponentFixture<ReportLabsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ReportLabsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ReportLabsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import {FastenApiService} from '../../services/fasten-api.service';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ResourceFhir} from '../../models/fasten/resource_fhir';
import * as fhirpath from 'fhirpath';
@Component({
selector: 'app-report-labs',
templateUrl: './report-labs.component.html',
styleUrls: ['./report-labs.component.scss']
})
export class ReportLabsComponent implements OnInit {
observationGroups: {[key: string]: ResourceFhir[]} = {}
observationGroupTitles: {[key: string]: string} = {}
constructor(
private fastenApi: FastenApiService,
) { }
ngOnInit(): void {
this.fastenApi.getResources("Observation").subscribe(results => {
console.log("ALL OBSERVATIONS", results)
//loop though all observations, group by "code.system": "http://loinc.org"
for(let observation of results){
let observationGroup = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().code")[0]
this.observationGroups[observationGroup] = this.observationGroups[observationGroup] ? this.observationGroups[observationGroup] : []
this.observationGroups[observationGroup].push(observation)
if(!this.observationGroupTitles[observationGroup]){
this.observationGroupTitles[observationGroup] = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().display")[0]
}
}
//TODO: sort observation groups
// this.observationGroups = results
//
// //populate a lookup table with all resources
// for (let condition of this.conditions) {
// this.recPopulateResourceLookup(condition)
// }
})
}
}

View File

@ -126,6 +126,16 @@ export class FastenApiService {
);
}
getResourceGraph(): Observable<{[resourceType: string]: ResourceFhir[]}> {
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph`)
.pipe(
map((response: ResponseWrapper) => {
console.log("RESPONSE", response)
return response.data as {[name: string]: ResourceFhir[]}
})
);
}
getResourceBySourceId(sourceId: string, resourceId: string): Observable<ResourceFhir> {
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/fhir/${sourceId}/${resourceId}`)

3
go.mod
View File

@ -4,6 +4,7 @@ go 1.18
require (
github.com/analogj/go-util v0.0.0-20210417161720-39b497cca03b
github.com/dominikbraun/graph v0.15.0
github.com/fastenhealth/fasten-sources v0.0.7
github.com/gin-gonic/gin v1.8.1
github.com/glebarez/sqlite v1.5.0
@ -14,6 +15,7 @@ require (
github.com/spf13/viper v1.12.0
github.com/urfave/cli/v2 v2.11.2
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17
gorm.io/datatypes v1.0.7
gorm.io/gorm v1.24.1
)
@ -57,7 +59,6 @@ require (
github.com/subosito/gotenv v1.3.0 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.2.0 // indirect
golang.org/x/oauth2 v0.2.0 // indirect
golang.org/x/sys v0.2.0 // indirect

2
go.sum
View File

@ -65,6 +65,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/denisenkom/go-mssqldb v0.12.0 h1:VtrkII767ttSPNRfFekePK3sctr+joXgO58stqQbtUA=
github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dominikbraun/graph v0.15.0 h1:U6N0fWJTDsUO9R5NhFE1Xi0YGGQ1+nKV9KO5apClf3E=
github.com/dominikbraun/graph v0.15.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=