Skip to content

Commit

Permalink
adding a glossary endpoint which attempts to get patient-friendly des…
Browse files Browse the repository at this point in the history
…criptions from code. (fastenhealth#120)
  • Loading branch information
AnalogJ committed Mar 21, 2023
1 parent fa75594 commit 390cea6
Show file tree
Hide file tree
Showing 17 changed files with 379 additions and 21 deletions.
5 changes: 4 additions & 1 deletion backend/pkg/database/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type DatabaseRepository interface {
GetSourceSummary(context.Context, string) (*models.SourceSummary, error)
GetSources(context.Context) ([]models.SourceCredential, error)

//used by Client
CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error
GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error)

//used by fasten-sources Clients
UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error)
}
25 changes: 22 additions & 3 deletions backend/pkg/database/sqlite_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger)
}
globalLogger.Infof("Successfully connected to fasten sqlite db: %s\n", appConfig.GetString("database.location"))

deviceRepo := SqliteRepository{
fastenRepo := SqliteRepository{
AppConfig: appConfig,
Logger: globalLogger,
GormClient: database,
}

//TODO: automigrate for now
err = deviceRepo.Migrate()
err = fastenRepo.Migrate()
if err != nil {
return nil, err
}
Expand All @@ -76,7 +76,7 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger)
return nil, fmt.Errorf("Failed to create admin user! - %v", err)
}

return &deviceRepo, nil
return &fastenRepo, nil
}

type SqliteRepository struct {
Expand All @@ -91,6 +91,7 @@ func (sr *SqliteRepository) Migrate() error {
&models.User{},
&models.SourceCredential{},
&models.ResourceFhir{},
&models.Glossary{},
)
if err != nil {
return fmt.Errorf("Failed to automigrate! - %v", err)
Expand Down Expand Up @@ -140,6 +141,24 @@ func (sr *SqliteRepository) GetCurrentUser(ctx context.Context) (*models.User, e
return &currentUser, nil
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Glossary
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

func (sr *SqliteRepository) CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error {
record := sr.GormClient.Create(glossaryEntry)
if record.Error != nil {
return record.Error
}
return nil
}

func (sr *SqliteRepository) GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error) {
var foundGlossaryEntry models.Glossary
result := sr.GormClient.Where(models.Glossary{Code: code, CodeSystem: codeSystem}).First(&foundGlossaryEntry)
return &foundGlossaryEntry, result.Error
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Summary
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
14 changes: 14 additions & 0 deletions backend/pkg/models/glossary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package models

// Glossary contains patient friendly terms for medical concepts
// Can be retrieved by Code and CodeSystem
// Structured similar to ValueSet https://hl7.org/fhir/valueset.html
type Glossary struct {
ModelBase
Code string `json:"code" gorm:"uniqueIndex:idx_glossary_term"`
CodeSystem string `json:"code_system" gorm:"uniqueIndex:idx_glossary_term"`
Publisher string `json:"publisher"`
Title string `json:"title"`
Url string `json:"url"`
Description string `json:"description"`
}
59 changes: 59 additions & 0 deletions backend/pkg/models/medlineplus_connect_results.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package models

import "time"

type MedlinePlusConnectResults struct {
Feed struct {
Base string `json:"base"`
Lang string `json:"lang"`
Xsi string `json:"xsi"`
Title struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"title"`
Updated struct {
Value time.Time `json:"_value"`
} `json:"updated"`
ID struct {
Value string `json:"_value"`
} `json:"id"`
Author struct {
Name struct {
Value string `json:"_value"`
} `json:"name"`
URI struct {
Value string `json:"_value"`
} `json:"uri"`
} `json:"author"`
Subtitle struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"subtitle"`
Category []struct {
Scheme string `json:"scheme"`
Term string `json:"term"`
} `json:"category"`
Entry []MedlinePlusConnectResultEntry `json:"entry"`
} `json:"feed"`
}

type MedlinePlusConnectResultEntry struct {
Title struct {
Value string `json:"_value"`
Type string `json:"type"`
} `json:"title"`
Link []struct {
Href string `json:"href"`
Rel string `json:"rel"`
} `json:"link"`
ID struct {
Value string `json:"_value"`
} `json:"id"`
Summary struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"summary"`
Updated struct {
Value time.Time `json:"_value"`
} `json:"updated"`
}
158 changes: 158 additions & 0 deletions backend/pkg/web/handler/glossary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
"github.com/fastenhealth/gofhir-models/fhir401"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)

func FindCodeSystem(codeSystem string) (string, error) {
log.Printf("codeSystem: %s", codeSystem)
if strings.HasPrefix(codeSystem, "2.16.840.1.113883.6.") {
return codeSystem, nil
}

//https://terminology.hl7.org/external_terminologies.html
codeSystemIds := map[string]string{
"http:https://hl7.org/fhir/sid/icd-10-cm": "2.16.840.1.113883.6.90",
"http:https://hl7.org/fhir/sid/icd-10": "2.16.840.1.113883.6.90",
"http:https://terminology.hl7.org/CodeSystem/icd9cm": "2.16.840.1.113883.6.103",
"http:https://snomed.info/sct": "2.16.840.1.113883.6.96",
"http:https://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88",
"http:https://hl7.org/fhir/sid/ndc": "2.16.840.1.113883.6.69",
"http:https://loinc.org": "2.16.840.1.113883.6.1",
"http:https://www.ama-assn.org/go/cpt": "2.16.840.1.113883.6.12",
}

if codeSystemId, ok := codeSystemIds[codeSystem]; ok {
return codeSystemId, nil
} else {
return "", fmt.Errorf("Code System not found")
}

}

// https://medlineplus.gov/medlineplus-connect/web-service/
// NOTE: max requests is 100/min
func GlossarySearchByCode(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)

codeSystemId, err := FindCodeSystem(c.Query("code_system"))
if err != nil {
logger.Error(err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
if c.Query("code") == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "code is required",
})
return
}

//Check if the code is in the DB cache
foundGlossaryEntry, err := databaseRepo.GetGlossaryEntry(c, c.Query("code"), codeSystemId)
if err == nil {
//found in DB cache
logger.Debugf("Found code (%s %s) in DB cache", c.Query("code"), codeSystemId)
dateStr := foundGlossaryEntry.UpdatedAt.Format(time.RFC3339)
valueSet := fhir401.ValueSet{
Title: &foundGlossaryEntry.Title,
Url: &foundGlossaryEntry.Url,
Description: &foundGlossaryEntry.Description,
Date: &dateStr,
Publisher: &foundGlossaryEntry.Publisher,
}

c.JSON(http.StatusOK, valueSet)
return
}

// Define the URL for the MedlinePlus Connect Web Service API
medlinePlusConnectEndpoint := "https://connect.medlineplus.gov/service"

// Define the query parameters
params := url.Values{
"informationRecipient.languageCode.c": []string{"en"},
"knowledgeResponseType": []string{"application/json"},
"mainSearchCriteria.v.c": []string{c.Query("code")},
"mainSearchCriteria.v.cs": []string{codeSystemId},
}

// Send the HTTP GET request to the API and retrieve the response
//TODO: when using IPV6 to communicate with MedlinePlus, we're getting timeouts. Force IPV4
var (
zeroDialer net.Dialer
httpClient = &http.Client{
Timeout: 10 * time.Second,
}
)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return zeroDialer.DialContext(ctx, "tcp4", addr)
}
httpClient.Transport = transport
resp, err := httpClient.Get(medlinePlusConnectEndpoint + "?" + params.Encode())
if err != nil {
fmt.Println("Error sending request:", err)
return
}
defer resp.Body.Close()

// Parse the JSON response into a struct
var response models.MedlinePlusConnectResults
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
fmt.Println("Error parsing response:", err)
return
}

if len(response.Feed.Entry) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "No results found"})
return
} else {
foundEntry := response.Feed.Entry[0]

dateStr := foundEntry.Updated.Value.Format(time.RFC3339)
valueSet := fhir401.ValueSet{
Title: &foundEntry.Title.Value,
Url: &foundEntry.Link[0].Href,
Description: &foundEntry.Summary.Value,
Date: &dateStr,
Publisher: &response.Feed.Author.Name.Value,
}

//store in DB cache (ignore errors)
databaseRepo.CreateGlossaryEntry(c, &models.Glossary{
ModelBase: models.ModelBase{
CreatedAt: foundEntry.Updated.Value,
UpdatedAt: foundEntry.Updated.Value,
},
Code: c.Query("code"),
CodeSystem: codeSystemId,
Publisher: response.Feed.Author.Name.Value,
Title: foundEntry.Title.Value,
Url: foundEntry.Link[0].Href,
Description: foundEntry.Summary.Value,
})

c.JSON(http.StatusOK, valueSet)
}
}
2 changes: 2 additions & 0 deletions backend/pkg/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
//r.Any("/database/*proxyPath", handler.CouchDBProxy)
//r.GET("/cors/*proxyPath", handler.CORSProxy)
//r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
api.GET("/glossary/code", handler.GlossarySearchByCode)

secure := api.Group("/secure").Use(middleware.RequireAuth())
{
Expand All @@ -61,6 +62,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
secure.GET("/resource/graph", handler.GetResourceFhirGraph)
secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir)
secure.POST("/resource/composition", handler.CreateResourceComposition)

}

if ae.Config.GetBool("web.allow_unsafe_endpoints") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div *ngIf="!loading else isLoadingTemplate">
<div [innerHTML]="description"></div>
<p>Source: <a [href]="url">{{source}}</a></p>
</div>

<ng-template #isLoadingTemplate>
<app-loading-spinner [loadingTitle]="'Please wait, loading glossary entry...'"></app-loading-spinner>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:host {
max-height:300px;
overflow-y:scroll;
display: inline-block;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { GlossaryLookupComponent } from './glossary-lookup.component';

describe('GlossaryLookupComponent', () => {
let component: GlossaryLookupComponent;
let fixture: ComponentFixture<GlossaryLookupComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ GlossaryLookupComponent ]
})
.compileComponents();

fixture = TestBed.createComponent(GlossaryLookupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {Component, Input, OnInit} from '@angular/core';
import {FastenApiService} from '../../services/fasten-api.service';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';

@Component({
selector: 'app-glossary-lookup',
templateUrl: './glossary-lookup.component.html',
styleUrls: ['./glossary-lookup.component.scss']
})
export class GlossaryLookupComponent implements OnInit {

@Input() code: string
@Input() codeSystem: string
@Input() snippetLength: number = -1

description: SafeHtml = ""
url: string = ""
source: string = ""
loading: boolean = true

constructor(private fastenApi: FastenApiService, private sanitized: DomSanitizer) { }

ngOnInit(): void {
this.fastenApi.getGlossarySearchByCode(this.code, this.codeSystem).subscribe(result => {
this.loading = false
console.log(result)
this.url = result?.url
this.source = result?.publisher
this.description = this.sanitized.bypassSecurityTrustHtml(result?.description)
// this.description = result.description
}, error => {
this.loading = false
})
}

}
Loading

0 comments on commit 390cea6

Please sign in to comment.