package adapters import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/missionworkforce/missionmatch/internal/modules/ontology" ) // NICESource ingests the NIST NICE Workforce Framework for Cybersecurity (SP 800-181 Rev 1) // components JSON published by the NIST Cybersecurity and Privacy Reference Tool (CPRT). // It yields one KindWorkRole record per work_role element and one KindKSA record per task, // knowledge, or skill element. Structural element types (sort, category, competency_area, // opm_code) are skipped. type NICESource struct { url string client *http.Client } // NewNICESource builds the adapter. url is the NICE components JSON location published by NIST CPRT. func NewNICESource(url string, client *http.Client) *NICESource { if client == nil { client = &http.Client{Timeout: 120 * time.Second} } return &NICESource{url: url, client: client} } // Name is the provenance label stamped on every record this source yields. func (s *NICESource) Name() string { return "NICE Framework" } // Fetch downloads the NICE components JSON and yields one record per work role and TKS element. func (s *NICESource) Fetch(ctx context.Context, yield func(ontology.VocabRecord) error) error { data, err := s.download(ctx) if err != nil { return err } recs, err := parseNICE(data) if err != nil { return err } for _, r := range recs { if err := yield(r); err != nil { return err } } return nil } func (s *NICESource) download(ctx context.Context) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) if err != nil { return nil, fmt.Errorf("nice: new request: %w", err) } resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("nice: get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("nice: provider returned %s", resp.Status) } return io.ReadAll(resp.Body) } // niceDocument mirrors the top-level shape of the NIST CPRT NICE components JSON // (v2-2-0_nf_components.json, confirmed from the live file at // https://csrc.nist.gov/csrc/media/Projects/cprt/documents/nice/v2-2-0_nf_components.json). type niceDocument struct { Elements []niceElement `json:"elements"` } // niceElement mirrors one entry in the elements array. The five fields below are the complete // set present in the real file; element_type values observed are: sort, category, competency_area, // opm_code, work_role, task, knowledge, skill. type niceElement struct { ElementType string `json:"element_type"` ElementIdentifier string `json:"element_identifier"` Title string `json:"title"` Text string `json:"text"` DocIdentifier string `json:"doc_identifier"` } // parseNICE decodes NICE components JSON bytes and returns one VocabRecord per work_role and // per TKS (task, knowledge, skill) element. All other element types are skipped. // // Schema notes confirmed from the live v2-2-0 file: // // work_role: identifier in element_identifier (e.g. OG-WRL-017), name in title, description in text. // task: identifier in element_identifier (e.g. T0006), title is empty, description in text. // knowledge: identifier in element_identifier (e.g. K0018), title is empty, description in text. // skill: identifier in element_identifier (e.g. S0011), title is empty, description in text. func parseNICE(data []byte) ([]ontology.VocabRecord, error) { var doc niceDocument if err := json.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("nice: decode JSON: %w", err) } out := make([]ontology.VocabRecord, 0, len(doc.Elements)) for _, el := range doc.Elements { id := strings.TrimSpace(el.ElementIdentifier) if id == "" { continue } switch el.ElementType { case "work_role": term := strings.TrimSpace(el.Title) if term == "" { term = strings.TrimSpace(el.Text) } if term == "" { continue } raw, _ := json.Marshal(map[string]string{ "element_type": el.ElementType, "element_identifier": id, "title": el.Title, "text": el.Text, }) out = append(out, ontology.VocabRecord{ SourceName: "NICE Framework", SourceRecordID: id, Kind: ontology.KindWorkRole, Term: term, Code: id, Category: el.ElementType, Description: strings.TrimSpace(el.Text), Raw: raw, }) case "task", "knowledge", "skill": term := strings.TrimSpace(el.Text) if term == "" { term = strings.TrimSpace(el.Title) } if term == "" { continue } raw, _ := json.Marshal(map[string]string{ "element_type": el.ElementType, "element_identifier": id, "title": el.Title, "text": el.Text, }) out = append(out, ontology.VocabRecord{ SourceName: "NICE Framework", SourceRecordID: id, Kind: ontology.KindKSA, Term: term, Code: id, Category: el.ElementType, Description: term, Raw: raw, }) default: // sort, category, competency_area, opm_code and any future structural types are skipped. } } return out, nil }