Skip to content
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

adding a glossary endpoint which attempts to get patient-friendly descriptions from code. #120

Merged
merged 7 commits into from
Mar 21, 2023
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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